Cartaの給与・株式報酬に関する市場水準データ(基本給、株式報酬、現金報酬合計)を職種別に取得します。チャットへの表示またはCSVファイルでの出力ができます。 **使用が想定される質問例:** 「営業職の給与水準」「報酬の市場相場」「◯◯職の給与はいくら?」「給与水準をCSVで出してほしい」「Cartaの市場水準を表示して」「◯◯職の給与範囲」「第25・50・75パーセンタイル値」など **使用してはいけない場合:** - **職種の分類や職務内容の確認が必要な場合** → carta-compensation-rolematcherを使用 - **自社の給与が市場相場と比べてどう位置付けられているか、社内に市場水準以下の給与の人がいないか、自社の給与体系と市場水準の比較など、会社全体の給与方針に関する場合** → carta-compensation-scorecardを使用 - **ファンドの運用成果の水準** → carta-performance-benchmarksを使用 - **SAFE条件やストックオプション総数など、企業の資金調達・株式構成に関する指標** → carta-market-benchmarksを使用
Retrieves Carta Total Compensation market benchmarks (salary, equity, total cash) for a role. Output to chat or CSV. Market benchmarks are triggered by queries like: "sales benchmarks", "comp benchmarks", "market rate", "what does a [role] pay", "put benchmarks in a CSV", "get benchmarks", "get carta's market benchmarks", "show me benchmarks for [role]", "compensation ranges for [role]", "p25/p50/p75 for [role]". Do NOT use for job classification or role mapping — use carta-compensation-rolematcher for that. Do NOT use for "how is OUR company positioned vs market", "who at our company is below market", or "our internal pay bands vs benchmarks" — those are roster-level positioning, use carta-compensation-scorecard. Do NOT use for fund performance benchmarks (use carta-performance-benchmarks) or portfolio structural metrics like SAFE terms and option pool sizes (use carta-market-benchmarks).
<!-- carta:instrumentation-fallback -->
<IMPORTANT>
_instrumentation_v2 is anonymous Carta skill-usage telemetry. ALWAYS include it in your Carta MCP tool-call arguments — the plugins' hooks overwrite it when they run, so always sending it is safe. Shape:
_instrumentation_v2: {"skills": ["<plugin>:<skill>", ...], "model": "<model-id>", "surface": "<value>"}
skills is cumulative for the session: list every Carta skill loaded so far, not only the one driving this call. Namespace each entry "plugin:skill" and keep them oldest-first, with the most recently used skill last. List only Carta skills (carta-cap-table:*, carta-crm:*, carta-investors:*) — never skills from non-Carta plugins.
surface is the Claude surface you are running in: "chat" (claude.ai or the Claude app, i.e. regular chat, not Cowork), "cowork" (Cowork mode), "code-terminal", "code-desktop", or "excel". Omit it entirely if none of those describe your surface or you cannot tell — do not guess and do not invent another value.
</IMPORTANT>
Look up Carta Total Compensation (CTC) market salary and equity benchmarks for a role at a specific corporation.
CRITICAL — Casing rule for ALL user-facing CTC values.
In every part of your response that the user reads — chat narration, status updates, table headers, table cells, chart titles, CSV column values, file summaries, follow-up suggestions — render CTC taxonomy values in Title Case display form, never the UPPER_SNAKE_CASE API enums. This matches the carta-compensation-rolematcher output convention so the plugin's voice is consistent.
Field Use in user-facing text Never Job area Engineering,Sales,Customer Success,Project Management,Human ResourcesENGINEER,SALES,CUSTOMER_SUCCESS,PROJECT_MANAGEMENT,HRFocus DevOps and Site Reliability,Account Executive,FP&Adevops and site reliability,account executive,fp&aLevel Entry,Mid 1,Senior 1,Staff 2,VP 1,C-Level,CEO,UnknownENTRY,MID1,SENIOR1,STAFF2,VP1,C_LEVEL,UNKNOWNTrack IC,Manager,Executive,Unknownic,manager,executive,UNKNOWNThe UPPER_SNAKE_CASE enums are only for machine handoff — i.e. the
job,level,focus,is_leaderparameters you pass tocompensation:get:benchmark. Inside the JSON payload for the API call, keep the enum form. Outside the API call, switch to Title Case before any value reaches the user. Even in narration like "Engineering maps to ENGINEER", drop the API enum — say "Pulling Engineering benchmarks for corp 7" instead.See
carta-compensation-rolematcher→ "Display → API enum tables" for the full mapping.
Use MCP, not CLI. Every API call in this skill goes through the carta MCP server's
mcp__carta__call_tooltool, withcompensation:*commands. Do NOT shell out to thecartaCLI (carta compensation ...,carta web ..., etc.) — that bypasses the formatters, the 403 handler, and the attribution requirement. The Bash tool is allowed only for writing CSV/JSON files locally, never for calling Carta APIs.Examples below use shorthand
call_tool({"name": "compensation__get__plan", "arguments": {...}})— read this asmcp__carta__call_tool({"name": "compensation__get__plan", "arguments": {...}}).
CRITICAL — Show only PERCENTILE columns (p25/p50/p75/p90) for all three rating types.
The
compensation:get:benchmarkresponse includes bothlow/mid/highbands ANDp25/p50/p75/p90percentiles. Surface only the percentiles — they are the raw market data. Skip the band fields entirely (they're a derived corp-specific target band that adds noise without adding information for benchmark queries).Every output (chat reply, CSV, JSON) MUST include all three rating types: salary, equity, AND total cash. Don't stop at salary.
Cowork vs everywhere else — pick ONE chat surface, not both
Where the benchmark numbers actually appear depends on the client:
Client Chat reply Live artifact panel ArtifactcallableOne-line acknowledgement + the data-source attribution line. NO markdown percentile tables. ✅ Renders the percentile tables Claude Code, Claude Desktop, claude.ai ✅ Renders the markdown tables (the "Chat reply format" below) + the attribution line Not available — skip the artifact path Anti-patterns:
- ❌ In Cowork, rendering the markdown percentile tables AND the artifact panel — the data appears twice, the chat reply is noise.
- ❌ In Claude Code / Desktop / claude.ai, skipping the markdown tables on the assumption an artifact will pick up the slack — the artifact doesn't render there, so the user gets nothing.
The Excel / CSV export paths are unchanged — both clients can request a file export and it works the same way regardless.
Chat reply format (single role) — Claude Code / Desktop / claude.ai only
Skip this entire section when running in Cowork — the artifact panel renders the same percentile data and a markdown duplicate is noise. Use the one-line acknowledgement format from the "Live artifact" section below instead.
Three small tables, one per rating type. Each has 4 columns: P25, P50, P75, P90.
## Market Benchmark: [Role] at [Company] **Salary** | P25 | P50 | P75 | P90 | |-----|-----|-----|-----| | $145,000 | $164,000 | $186,000 | $210,000 | **Total Cash Compensation (TCC)** | P25 | P50 | P75 | P90 | |-----|-----|-----|-----| | $164,000 | $185,000 | $210,000 | $237,000 | **Equity (4-Year Grant)** | Metric | P25 | P50 | P75 | P90 | |--------|-----|-----|-----|-----| | FD % | 0.030% | 0.040% | 0.050% | 0.144% | | Shares | 18,620 | 24,745 | 30,870 | 88,444 | | Notional value | $100,000 | $133,000 | $165,000 | $474,000 | (For peer groups ≥ $500M post money — `peer_group.notional_available: true` — put **Notional value** as the first row instead.) **Geo Adjustment:** [location] (X.XX× salary, X.XX× equity) --- *Data source: Companies with [peer_group_dimension_phrase] [peer_group_label]. Benchmarks released [Month YYYY].*The
[peer_group_dimension_phrase]varies bypeer_group.dimension— see "Required attribution" below for the three exact phrasings. Do NOT hardcodepost money valuations between.CSV format (bulk)
One row per
(job, ladder, level). Default column order (peer group < $500M post money):job, ladder, level, currency, salary_p25, salary_p50, salary_p75, salary_p90, tcc_p25, tcc_p50, tcc_p75, tcc_p90, equity_fd_pct_p25, equity_fd_pct_p50, equity_fd_pct_p75, equity_fd_pct_p90, equity_shares_p25, equity_shares_p50, equity_shares_p75, equity_shares_p90, equity_notional_p25, equity_notional_p50, equity_notional_p75, equity_notional_p90For peer groups ≥ $500M post money (
peer_group.notional_available: true), notional comes first:..., equity_notional_p25..p90, equity_fd_pct_p25..p90, equity_shares_p25..p90Field source map (from each
benchmarks[i]entry):
salary_p*→salary_benchmarks.percentiles.{p25,p50,p75,p90}tcc_p*→tcc_benchmarks.percentiles.{p25,p50,p75,p90}equity_shares_p*→equity_benchmarks.percentiles.{p25,p50,p75,p90}.as_sharesequity_fd_pct_p*→equity_benchmarks.percentiles.{p25,p50,p75,p90}.as_fd_percentageequity_notional_p*→equity_benchmarks.percentiles.{p25,p50,p75,p90}.as_notional_valuecurrency→salary_benchmarks.currency_codeNote: equity percentiles are nested objects (
percentiles.p25.as_shares, etc.), not flat values like salary/tcc.If a column's source field is missing for a particular row (e.g. some roles have no equity), leave that cell blank — do not invent zeros and do not drop the column.
Anti-patterns:
- ❌ Showing the user a low/mid/high table. Those are the corp's pay-band target, not market data — skip them.
- ❌ CSV with
salary_low / salary_mid / salary_highcolumns instead of percentile columns.- ❌ Salary-only output. The user asked for "benchmarks" — show all three rating types.
- ❌ Skipping TCC because "the user said sales benchmarks" — TCC IS a benchmark.
CRITICAL — Excel exports MUST use the branded export script
When the user asks for an Excel (.xlsx) file, you MUST call the export script below. Do not write openpyxl code yourself. Do not choose colors, fonts, or layout — the script applies Carta's official brand guidelines automatically (teal headers, alternating rows, "Powered by Carta" logo, attribution row). Any hand-rolled Excel output will have incorrect branding.
Anti-patterns:
- ❌ Writing
from openpyxl import Workbookand styling cells yourself- ❌ Choosing your own header colors (navy, blue, or anything else)
- ❌ Skipping the logo — the script embeds it automatically from the plugin's assets
- ❌ Omitting
--notional-firstwhenpeer_group.notional_availableis true
Step 1 — Build a JSON array of row objects (one dict per (job, ladder, level)). Use null for missing values, never 0 or empty string.
{
"job": "ENGINEER", "ladder": "IC", "level": "SENIOR1", "currency": "USD",
"salary_p25": 145000, "salary_p50": 164000, "salary_p75": 186000, "salary_p90": 210000,
"tcc_p25": 164000, "tcc_p50": 185000, "tcc_p75": 210000, "tcc_p90": 237000,
"equity_fd_pct_p25": 0.0003, "equity_fd_pct_p50": 0.0004, "equity_fd_pct_p75": 0.0005, "equity_fd_pct_p90": 0.00144,
"equity_shares_p25": 18620, "equity_shares_p50": 24745, "equity_shares_p75": 30870, "equity_shares_p90": 88444,
"equity_notional_p25": 100000, "equity_notional_p50": 133000, "equity_notional_p75": 165000, "equity_notional_p90": 474000
}
Step 2 — Write the rows to a temp JSON file (avoids shell argument length limits):
Write /tmp/benchmarks_export.json ← the JSON array
Step 3 — Run the export script:
uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-compensation-benchmarks/scripts/export_benchmarks.py \
--data @/tmp/benchmarks_export.json \
--output <output_path>.xlsx \
--attribution "<full attribution string>" \
[--notional-first] # required when peer_group.notional_available is true
The script handles all branding automatically — do not modify its output styling. The attribution string must also appear in the chat reply per the attribution rules above.
The live artifact panel is an enhancement, not a replacement for the chat experience. Every surface gets the same data and the same Excel export path. The artifact only changes HOW that data is presented:
- Where
Artifactis callable — publish the panel for every benchmark query, no opt-in trigger required. Words like "interactive", "visualize", "explore" are no longer needed. The chat reply that accompanies the panel MUST be a one-line acknowledgement (e.g. "Published the benchmark panel for <Role> at <Company> — <URL>.") plus the standard data-source attribution line (see "Required attribution" below) — and NOTHING ELSE. No markdown percentile tables, no per-rating-type sub-tables, no salary/TCC/equity numbers in chat. The artifact panel owns those numbers; duplicating them in chat is noise. The attribution stays in chat because the panel doesn't render it.- Where it is not — present the same data inline using the "Chat reply format (single role)" or CSV/Excel export paths. Skip the artifact steps below.
Why no
preview_startpath? The panel's interactive controls (corp search, refetch, Download Excel) call Carta through the published artifact'smcpcapability, which only a published artifact has. Inpreview_start, the panel would render but the buttons would silently fail. Publishing is the only surface where the panel functions fully; the chat-only path covers everywhere else equivalently.
Step 0 — Pick the rendering path
Artifact is callable → use the artifact panel path below (Steps 1, 2, 4). Default for every benchmark query — no trigger phrase needed.Step 1 — Build the benchmark payload JSON
Serialize the fetched benchmark results into this shape:
{
"company": { "id": 7, "name": "Acme Corp" },
"results": [ /* array of row objects — see the per-row shape below */ ],
"version": "v3.1",
"benchmark_version_id": 51,
"peer_group": { "dimension": "post_money", "code": "ONE_HUNDRED_MILLION", "label": "$100M-$250M" },
"fetchedAt": "2026-05-27T10:00:00Z"
}
benchmark_version_idis REQUIRED in the payload (the numericbenchmark_version.idfromcompensation:get:plan/ the benchmark response — NOT the"v21.0"display string). The artifact's interactive controls (changing level, location, adding a row) re-fetch viacompensation:get:benchmarkand must pin the same benchmark version the pre-seed used; omitting it causes those re-fetches to fail. The top-levelversionstring is display-only.
peer_groupis REQUIRED in the payload — the same{dimension, code, label}you captured fromcompensation:get:planin Step 3b. The artifact's interactive re-fetches pass<dimension>_bucket: <code>on every call so the panel's numbers match the corp's plan-configured peer group (and the CTC product UI). Omitting it makes those re-fetches fall back to a default comparable set whose values diverge from the FE — the exact mismatch users report.codeis the bucket enum (e.g.ONE_HUNDRED_MILLION), NOT thelabeldisplay string.
The artifact's results[] row shape is NESTED — NOT the flat CSV shape. The engine's renderTable reads r.salary.p25, r.equity.p50.notional, etc. Pre-seeding flat rows (e.g. r.salary_p25) makes every cell render as —. Use this shape per row:
{
"job": "ENGINEER", "level": "SENIOR1",
"ladder": "IC",
"currency": "USD",
"location": "San Francisco,CA,USA",
"geo": "San Francisco-Oakland-Hayward, CA",
"version": "v24.9",
"error": null,
"salary": { "p25": 145000, "p50": 164000, "p75": 186000, "p90": 210000 },
"tcc": { "p25": 164000, "p50": 185000, "p75": 210000, "p90": 237000 },
"equity": {
"p25": { "notional": 100000, "shares": 18620, "fdpct": 0.0003 },
"p50": { "notional": 133000, "shares": 24745, "fdpct": 0.0004 },
"p75": { "notional": 165000, "shares": 30870, "fdpct": 0.0005 },
"p90": { "notional": 474000, "shares": 88444, "fdpct": 0.00144 }
}
}
Field mapping from the compensation:get:benchmark response:
salary.p* ← salary_benchmarks.percentiles.p* (numeric)tcc.p* ← tcc_benchmarks.percentiles.p* (numeric)equity.p*.notional ← equity_benchmarks.percentiles.p*.as_notional_valueequity.p*.shares ← equity_benchmarks.percentiles.p*.as_sharesequity.p*.fdpct ← equity_benchmarks.percentiles.p*.as_fd_percentageladder ← benchmarks[i].ladder ("IC" or "LEADER"). The artifact derives the row's track from this + the level: IC → IC track; LEADER with level rank ≤8 → Manager track; LEADER with level rank ≥9 (VP1+) → Executive track. This is what makes the displayed track and per-track level name (e.g. VP1 shows as "Distinguished" on IC but "Vice President" on Executive) match the CTC product UI — pass ladder through verbatim.currency ← salary_benchmarks.currency_code (fall back to tcc_benchmarks.currency_code; surface null if neither is present — do NOT default to "USD")location ← the API location string you passed as the location param to compensation:get:benchmark (the "City,ST,USA" form, e.g. "San Francisco,CA,USA"; ",,US" for national). This is what pre-seeds the artifact's per-row location dropdown — it must be the API value, NOT the display label. Omit or set null when you queried without a location (the dropdown then seeds to "Any").geo ← geo_adjustment.label (the MSA display label, e.g. "San Francisco-Oakland-Hayward, CA"). Display-only — drives the read-only "Location:" line, NOT the dropdown selection. Keep it distinct from location: the label is not a valid API value and must never be sent back as the location param.version ← benchmark_version.version_major and version_minor concatenated as "v<major>.<minor>"error ← null for successful rows; populate with a short string when a per-job/level fetch failed so the table can render an explicit error cell instead of fabricating zerosUse null for any percentile value the API didn't return — never 0 or "".
⚠ Do not confuse this with the Excel export's row shape. The Excel export script (
export_benchmarks.py) consumes a flat row shape (salary_p25,equity_shares_p50, etc., documented in the Excel section above). The artifact engine consumes the nested shape documented here. Keep them separate — they are two independent contracts with different consumers.
Write the payload to /tmp/benchmark_payload_<corp_id>.json.
Step 2 — Publish the artifact panel
Read the engine HTML, replace {{CARTA_MCP_SERVER}} with the Carta connector's display
name, and inject the payload as a <script> block before the engine's own JavaScript runs:
Read ${CLAUDE_PLUGIN_ROOT}/skills/carta-compensation-benchmarks/assets/artifact_engine.html
Write <script>window._BENCHMARK_PAYLOAD = <payload JSON>;</script> + engine HTML to
comp-benchmarks-<company-slug>.html. The script tag MUST appear in the document before
the engine's main <script> block; injecting after the engine's own boot IIFE runs is too
late — the engine will treat the panel as interactive-only and skip the pre-seed.
Then check for an existing one with Artifact({action: "list", scope: "mine"}) and publish:
Artifact({
file_path: "comp-benchmarks-<company-slug>.html",
url: "<url of the existing panel for this company — omit on a first publish>",
title: "Compensation Benchmarks — <Company Name>",
description: "Compensation benchmarks for <Company Name>",
favicon: "💰",
capabilities: {
mcp: { servers: [{ server: "<CARTA_MCP_SERVER>", tools: ["list_accounts", "fetch"] }] }
}
})
The panel's own controls (corp search, refetch, Download Excel) call Carta at runtime, so
both tools must be in the grant or those controls fail with not_in_manifest. Re-renders
for the same company reuse the same url, so redeploying in place is the common case
after the first publish.
CRITICAL — Required attribution on every benchmark response
Whenever you surface ANY Carta Total Compensation benchmark data (single lookup, bulk table, comparison, follow-up answer, CSV, Markdown, JSON export — anything that contains target $, percentile, compa-ratio, score, or per-role/level numbers), you MUST include the attribution string in EVERY output channel — chat reply AND every file you generate.
The exact string
Data source: Companies with <peer_group_dimension_phrase> <peer_group_label>. Benchmarks released <Month> <YYYY>.Three placeholders, all required:
<peer_group_dimension_phrase>— depends on which peer-group dimension the corp's plan uses (peer_group.dimensionfromcompensation:get:plan). Pick one of three exact phrasings — do NOT hardcodepost money valuations betweenregardless of the corp:
post_money→ "post money valuations between"capital_raised→ "capital raised between"headcount→ "headcount of"<peer_group_label>— comes fromcompensation:get:plan→peer_group.label(e.g."$50M-$100M","$1M-$10M","100-500 employees"). This identifies the band the corp is benchmarked against. Always include it — the citation is incomplete without it.<Month> <YYYY>— a calendar date derived from the benchmark version'screatedISO timestamp. NOT a version number.Examples of correct values:
peer_group.dimensionpeer_group.labelbenchmark_version.createdCorrect attribution post_money"$50M-$100M""2026-05-06T14:42:41.646134Z"Data source: Companies with post money valuations between $50M-$100M. Benchmarks released May 2026.post_money"$500M-$1B""2026-02-15T08:00:00Z"Data source: Companies with post money valuations between $500M-$1B. Benchmarks released February 2026.capital_raised"$1M-$10M""2025-06-26T21:19:22Z"Data source: Companies with capital raised between $1M-$10M. Benchmarks released June 2025.capital_raised"$10M-$25M""2025-11-30T23:59:59Z"Data source: Companies with capital raised between $10M-$25M. Benchmarks released November 2025.headcount"100-500 employees""2026-01-15T08:00:00Z"Data source: Companies with headcount of 100-500 employees. Benchmarks released January 2026.Anti-patterns — do NOT do these:
- ❌ Hardcoding
post money valuations betweenfor acapital_raisedorheadcountcorp — the phrase MUST trackpeer_group.dimension- ❌ Omitting the peer-group sentence — the citation must always name the comparison set
- ❌
Data source: ... released v24.6— that's the version number, not the date- ❌
Data source: ... released benchmark v24.6 (May 2026)— drop the version, just use the month + year- ❌ Omitting it from the CSV because "the chat reply has it"
- ❌ Putting it only in a separate "Source" sheet without also placing it visibly in the data
- ❌ Using
version_major,version_minor, or theversionstring anywhere in the attribution- ❌ Using the internal enum code instead of the human label (
$50M-$100M) — always usepeer_group.label, neverpeer_group.codeWhere to place it
Output type Placement (required) Chat reply Last line of the message, italicized, after a ---horizontal ruleMarkdown file Last line of the file, italicized, after a ---horizontal ruleCSV file Final row, e.g. Data source,Companies with capital raised between $1M-$10M. Benchmarks released June 2025.(use 1 cell or split across 2; both work)JSON export Top-level "_source": "Companies with capital raised between $1M-$10M. Benchmarks released June 2025."field(The examples above use
capital_raisedphrasing as a reminder that the dimension phrase is not always "post money valuations between" — swap in the phrase that matches the corp'speer_group.dimension.)Pre-send checklist (run before every response that touches benchmark data)
- Did I read
peer_group.dimensionfrom thecompensation:get:planresponse and pick the matching phrase (post money valuations between/capital raised between/headcount of) — NOT a hardcoded "post money"?- Did I read
peer_group.labeland put it in the citation?- Did I derive the date from the benchmark version's
createdISO timestamp? (Not fromversion,version_major,version_minor.)- Did I format it as
<Month name> <YYYY>with no version number?- Is the attribution in the chat reply?
- If I generated a file, is the attribution INSIDE the file too?
- If multiple versions were used, did I list each with its own date?
If any answer is no, fix it before sending. This is non-negotiable, even when the user asks for terse output.
Do this ONCE, upfront, before calling any compensation endpoint. Do not start fetching subscription status or plan data until you have a confirmed
corporation_id.
Resolve in this priority order — stop as soon as one path succeeds:
Path 1 — Explicit numeric ID in the prompt (highest priority, no API call needed)
If the user mentioned a numeric corporation ID anywhere (e.g. "corp 7", "corp id 7", "corporation_id=7", "for company 728"), use that exact integer. Do not call list_accounts. Do not search for it. Do not substitute a similar-looking ID.
Anti-patterns:
- ❌ User says "corp 7" → agent calls
list_accounts(search="7")—list_accountssearches by name substring, not ID. "7" matches every corp with "7" in its name.- ❌ User says "corp 7" → agent picks a corp from a previous turn. Each prompt's corp ID overrides any prior context.
Path 2 — Company name in prompt
If the user named a company (e.g. "benchmarks for Acme"), call list_accounts(search="Acme"). Filter results to entries where id starts with corporation_pk:. If exactly one match, use it. If multiple, proceed to Path 4.
HARD RULE — only ever use a name and
corporation_pkthat appear verbatim in thelist_accountsresponse. The corp you act on MUST be one returned by the API for this query, copied exactly. Never:
- invent, complete, or correct a company name the API didn't return,
- blend or merge two different returned names into one (e.g. seeing "Acme Labs" and "Acme Health" and proceeding with "Acme"),
- assume a corp exists because the user named it — if
list_accounts(search=...)returns it, it exists; if it doesn't, it doesn't,- reuse a name/ID remembered from earlier in the conversation instead of the current response.
If the search returns no
corporation_pk:matches, do NOT guess or substitute the closest-looking corp. Tell the user you couldn't find a company by that name and ask them to confirm the exact name or give the numeric corp ID — then re-runlist_accounts. A benchmark for the wrong (or a non-existent) corp is worse than asking again.
Path 3 — Single account (auto-select, no question needed)
If the user gave no corp hint at all, call list_accounts() with no search. Filter to corporation_pk: entries. If exactly one corporation is returned, use it automatically — do not ask the user to confirm something they have no choice about.
Path 4 — Multiple accounts (ask once, cleanly)
If multiple corporations are found, use AskUserQuestion immediately:
list_accounts response (cap at 10; offer "Other" if more). Do not paraphrase, shorten, or normalize the names — present them exactly as returned so the user picks a real corp.After the user picks, map their selection back to the exact list_accounts entry it came from and use that entry's corporation_pk. If the user typed a free-text answer via "Other" that doesn't match a returned name, treat it as a new name hint and re-run Path 2 — do not approximate it to one of the listed corps.
Do not show the user a raw JSON dump of accounts. Do not attempt any compensation call before they answer.
HARD STOP — user dismissed the question:
If the user closed the prompt, said "cancel", "never mind", or otherwise did not select an option — STOP. Do not guess a corp. Do not call any compensation endpoint. Reply:
"No problem — let me know which corporation to look up benchmarks for (name or numeric ID) when you're ready. If there's something else I can help with in the meantime, just ask."
Picking a corp the user didn't authorize would return data for the wrong company. There is no recovery from that mistake.
Path 5 — No corporations at all (Fund-Admin-only user — STOP, do not ask)
If Paths 2/3/4 returned zero corporation_pk: entries, the user may have no cap table access at all — a Fund Admin user whose access is fund accounting only. Confirm before doing anything else:
call_tool({"name": "context_tools__get__profile", "arguments": {}}) — returns corporations[], the corporations the user holds a cap-table role on (it excludes NO_ACCESS roles, and returns [] for a user with no corporation roles).corporations[] is empty → the user has no cap table. Send the no-cap-table CTC message from Subscription gating below (the "your firm" variant) and STOP. Do not ask them to name a corporation — they don't have one. Do not call any compensation endpoint.corporations[] is non-empty → the user does have cap tables; the name search just missed. Do not send an upsell. Ask them to confirm the exact company name or numeric corp ID (same handling as a Path 2 miss) and re-resolve.Why
profileand notlist_accountsfor this check:list_accountsgroups corporations and funds under the samecorporation_pk:prefix, so a Fund Admin user's funds can read as cap table access.context_tools:get:profilereturns corporations only.
Note: list_contexts / set_context are for Fund Admin firms — they do not return corporations. Always use list_accounts for corporation lookup.
Extract the numeric corporation_pk (the integer after corporation_pk:) for all subsequent calls.
STOP. The subscription check is a hard gate. Do not ask for — or even mention — the role until
is_subscribed: truecomes back.Asking for the role is Step 3a, and Step 3a does not begin until this step returns
is_subscribed: true. In the turn where you run the subscription check, your message to the user must contain only that you are verifying CTC access — nothing about a role, job title, level, or "in the meantime / simultaneously / while that runs." A corp with no CTC subscription (or no caller access) has no benchmark data, so any role the user gives would be wasted effort. Verify first; ask for the role only once you know there is data to return.Anti-patterns — never do these (they defeat the gate):
- ❌ "Let me check the subscription and get the role from you simultaneously…"
- ❌ "Verifying access — meanwhile, what role do you want benchmarks for?"
- ❌ "Meetly has a subscription. What role…" bundled into the same turn as outcomes you haven't branched on yet
- ❌ Calling the
carta-compensation-rolematcherskill, or asking the user for a title/level, beforeis_subscribed: trueThe role question is a separate turn that happens after a confirmed
is_subscribed: true.
call_tool({"name": "compensation__get__subscription_status", "arguments": {"corporation_id": <corporation_pk>}})
Three outcomes:
is_subscribed: true → only now proceed to Step 3a and ask for the role.is_subscribed: false → stop and send the subscription message (see Subscription gating). Do not call plans/ or benchmark/ — they return empty data anyway and waste a round-trip. Do not invoke the rolematcher — don't make the user level a role we can't benchmark.Invoke the carta-compensation-rolematcher skill to classify free-text job titles, descriptions, or pasted job postings into the CTC taxonomy. Use the Skill tool, not Read:
Skill("carta-compensation-rolematcher")
Pass the user's role description as input. Do not freelance the mapping — the rolematcher has the canonical job_area / focus / level / track logic and will return values that align with the CTC enums.
When to invoke: anytime the user provides a job title or job description in the context of a benchmark/comp conversation, even if their phrasing sounds like something else. Treat all of these as rolematcher invocations:
If the user's question is ambiguous between "classify this for benchmark" vs "help me write the JD copy" vs "give me career advice", invoke the rolematcher first to get the classification, then use the result to answer their actual question. Do not skip the rolematcher and freelance — the user is in a comp/benchmark conversation.
Capture the output:
job_area — passed to the API as job, NOT as job_area (see the rename note below). Must be one of: ACCOUNTING, ADMIN, CEO, CORPORATE_AFFAIRS, CUSTOMER_SUCCESS, DATA, DESIGN, ENGINEER, FINANCE, HR, IT, LEGAL, MANUFACTURING, MARKETING, OPERATIONS, PRODUCT, PROJECT_MANAGEMENT, RESEARCH, SALES, STRATEGY, SUPPORT, OTHERfocus (e.g. "backend", "devops and site reliability", null) — job-area-dependent; the rolematcher returns lowercase multi-word strings matching the taxonomy verbatim — pass them through as-is to the APIlevel — must be one of (low to high seniority): ENTRY, MID1, MID2, SENIOR1, SENIOR2, STAFF1, STAFF2, PRINCIPAL, VP1, VP2, C_LEVEL, CEOtrack — the value returned by the rolematcher (ic, manager, executive, or UNKNOWN). Map to is_leader: manager or executive → true, ic → false. If UNKNOWN, stop and ask the user before calling the API — see Error Handling.Rename the field on handoff:
job_area→job. The rolematcher's output label and the CTC product both say "job area", but the parameter oncompensation:get:benchmarkisjob. Passingjob_areareturns HTTP 400 — there is nojob_areaparameter on any compensation endpoint.❌
{"job_area": "ENGINEER", "level": "SENIOR1"}✅{"job": "ENGINEER", "level": "SENIOR1"}
focusandlevelkeep their names; onlyjob_areais renamed.
If the rolematcher returns a value not in these enums (e.g. LEAD1, PRODUCT_MANAGER), map it to the closest valid value before calling the API. If unsure, read the valid enum list from search_tools({"query": "compensation get benchmark"}) — do NOT guess a plausible-looking name or invent a compensation:list:* command to look it up. Ask the user if still ambiguous.
If the user provides only a job title, that is sufficient minimum input for the rolematcher.
Anti-patterns:
carta-compensation-rolematcher for the classificationcall_tool({"name": "compensation__get__plan", "arguments": {"corporation_id": <corporation_pk>}})
Capture three things from the response:
benchmark_version.id — use as benchmark_version_id in the next step.peer_group — {code, label, dimension, notional_available}. The label (e.g. "$50M-$100M") is required for the data-source footnote. The dimension — one of post_money / capital_raised / headcount — selects BOTH the data-source attribution phrase (see "Required attribution") AND which bucket param to pass in Step 4. Many corps default to capital_raised, NOT post_money — do not assume. The notional_available boolean tells you the equity column order (see Step 5).peer_group.dimension is missing or not one of those three values, follow Step 4a (STOP).CRITICAL — Valid enum values: read them, don't guess them. Two failure modes to avoid.
Every filter param on
compensation:get:benchmark(job,level,focus, the three*_bucketparams,equity_quantity) takes a fixed UPPER_SNAKE_CASE enum value. The API validates by exact enum name and returns HTTP 400 for anything else. Two things burn retries:1. There is NO
compensation:list:*command for these enums. Do not invent one.compensation:list:job_types,list:jobs,list:peer_groups,list:post_money_buckets,list:capital_raised_buckets,list:headcount_buckets— none of these exist. Calling them returnsUnknown commandand wastes a turn. The onlycompensation:list:*command iscompensation:list:benchmark_versions. To see the valid filter values, read thecompensation:get:benchmarkcommand help — it enumerates everyjob,level, and bucket value:search_tools({"query": "compensation get benchmark"})Read the enum lists from that help; never self-discover via a guessed
list:command.2. Pass the enum NAME, not the human display label. The API wants
MARKETING, not"Marketing";CUSTOMER_SUCCESS, not"Customer Success"or"Customer Support";SENIOR1, not"Senior 1". For buckets, pass the enum name (TWENTY_FIVE_MILLION), not the dollar label ("$25M-$50M"). The Title-Case forms are for user-facing text only (see the casing rule at the top of this file) — they are never valid API values. If you only have a free-text role, that's what thecarta-compensation-rolematcherin Step 3a is for; it returns canonical enum names. Do not hand-translate a display label into a guessed enum.Anti-patterns (all observed in real failures):
- ❌
call_tool({"name": "compensation__list__job_types"})→Unknown tool. Readsearch_tools({"query": "compensation get benchmark"})instead.- ❌
job: "Marketing"/job: "Engineering"/job: "Customer Support"→ HTTP 400. UseMARKETING/ENGINEER/CUSTOMER_SUCCESS.- ❌
job: "PRODUCT_MANAGER"→ HTTP 400 (invented). The value isPRODUCT. When unsure, read the help — don't guess a plausible-looking name.- ❌
capital_raised_bucket: "$250M-$500M"(a label) or a fabricated name → HTTP 400. Pass a realCapitalRaisedBucketsname from the help.Bucketing across many job functions? Iterate over the valid
jobenum names from the help — do not loop over display labels.
call_tool({"name": "compensation__get__benchmark", "arguments": {
"corporation_id": <corporation_pk>,
"job": <job_area>, # omit to get ALL job areas
"level": <level>, # omit to get ALL levels for the job
"focus": <focus>, # omit if null
"is_leader": <true if track == "manager" or track == "executive" else false>,
"benchmark_version_id": <benchmark_version.id>,
"location": <location string>, # optional, for geo adjustment
# --- The corp's plan-default peer group. Include EXACTLY ONE bucket
# param. Do NOT include two or three. Pick the key by string-mapping
# `peer_group.dimension` from the plan response:
#
# peer_group.dimension key to use
# ──────────────────────── ─────────────────────────
# "post_money" → "post_money_bucket"
# "capital_raised" → "capital_raised_bucket"
# "headcount" → "headcount_bucket"
# null / unknown → STOP — see Step 4a below
#
# The value is always `peer_group.code` from the plan response.
"<peer_group_dimension>_bucket": <peer_group.code>, # ONLY ONE — see mapping above
# ⚠ DO NOT include the other two bucket params in the same call. The enum
# values are dimension-specific (PostMoneyBuckets vs CapitalRaisedBuckets
# vs HeadcountBuckets are disjoint), so a code value valid for one bucket
# param is invalid for the other two — including more than one will either
# silently override (which one wins is undefined and may change) or return
# HTTP 400.
#
# Example: Meetly's plan returns peer_group = {dimension: "post_money",
# code: "ONE_HUNDRED_MILLION", label: "$100M-$250M"}. The right call passes:
# "post_money_bucket": "ONE_HUNDRED_MILLION"
# Not:
# "capital_raised_bucket": "ONE_HUNDRED_MILLION" ← 400 Bad Request
# (also wrong: passing both — only one bucket param per call)
"equity_quantity": "FOUR_YEAR_GRANT" # REQUIRED — match the CTC product UI default
}})
equity_quantitydefaults toNTM_VESTINGon the MCP side, but the CTC product UI defaults toFOUR_YEAR_GRANT. Always passFOUR_YEAR_GRANTexplicitly so the skill's numbers tie out against the in-product UI. If you omit it, you'll return ~25% of the value HR users expect — that's a hard tie-out failure, not a stylistic preference. Applies to every benchmark call: single role, bulk CSV, live artifact panel, Excel export.
equity_quantity(EquityQuantity) has exactly TWO valid values. There are no others.
Value Meaning FOUR_YEAR_GRANTTotal value of the full four-year grant. Use this — matches the CTC product UI. NTM_VESTINGValue vesting over the next twelve months (≈25% of the four-year grant). ❌
ONE_YEAR_GRANT→ HTTP 400. This is the most common invented value — do not use it, not even to express "one year of vesting". The concept you want isNTM_VESTING. ❌ANNUAL,ONE_YEAR,TWO_YEAR_GRANT,THREE_YEAR_GRANT,NTM,NEXT_TWELVE_MONTHS,REFRESH_GRANT→ all HTTP 400. None exist.Why
ONE_YEAR_GRANTlooks plausible but is wrong: compensation-service's internal field for this concept is namedone_year_grant_benchmark(its source comment notes it "used to be known asntm_vesting_benchmark"). That internal name is not the API enum and never reaches the wire. If the user asks for "one year of equity" or "annual equity value", map it toNTM_VESTING.
Peer-group override (user-driven sensitivity analysis). When the user explicitly asks to see a different peer group than the corp's plan default ("show me $10M-$25M benchmarks instead" or "what would this look like for a 100-500 person company"), DROP the plan-default bucket param entirely and replace it with the override. Do not include both — the API's behavior when more than one bucket is non-null is undefined and may change.
So a Meetly-corp call that normally has post_money_bucket: "ONE_HUNDRED_MILLION" (plan default), when the user asks for "show me the $1M-$10M raised peer group instead", becomes:
call_tool({"name": "compensation__get__benchmark", "arguments": {
"corporation_id": 7, "job": "ENGINEER", "level": "ENTRY",
# post_money_bucket DROPPED — replaced by capital_raised_bucket below
"capital_raised_bucket": "ONE_TO_TEN_MILLION",
"equity_quantity": "FOUR_YEAR_GRANT",
...
}})
User-phrasing → override mapping:
| User says | Bucket param key | Bucket value (note: each dimension's enum uses different naming) |
|---|---|---|
| "$10M-$25M valuation" | post_money_bucket |
TEN_MILLION (post-money enum uses single-lower-bound names) |
| "$25M-$50M valuation" | post_money_bucket |
TWENTY_FIVE_MILLION |
| "$1M-$10M raised" | capital_raised_bucket |
ONE_TO_TEN_MILLION (capital-raised enum uses range names) |
| "$10M-$25M raised" | capital_raised_bucket |
TEN_TO_TWENTY_FIVE_MILLION |
| "100-500 employees" | headcount_bucket |
HUNDRED_TO_FIVE_HUNDRED |
| "25-100 employees" | headcount_bucket |
TWENTY_FIVE_TO_HUNDRED |
Important: the naming asymmetry between the three enums is intentional, not a typo.
PostMoneyBuckets names a bucket by its lower bound only: TEN_MILLION IS the $10M-$25M band.CapitalRaisedBuckets and HeadcountBuckets name a bucket by its explicit range: TEN_TO_TWENTY_FIVE_MILLION is the $10M-$25M band.Do NOT "fix" post_money_bucket: "TEN_MILLION" to post_money_bucket: "TEN_TO_TWENTY_FIVE_MILLION" thinking it's a typo. The post-money enum has no _TO_ form — passing TEN_TO_TWENTY_FIVE_MILLION returns HTTP 400.
Reference for the full enum sets is in compensation:get:benchmark's help (run search_tools({"query": "compensation get benchmark"}) to verify a specific value). There is no compensation:list:* command for bucket enums — read them from the command help.
peer_group.dimension (STOP)If compensation:get:plan returned a peer_group.dimension value that is not one of post_money / capital_raised / headcount, OR peer_group.dimension is null / absent entirely, stop. Do not attempt the benchmark fetch. Do not guess a bucket param.
Tell the user:
"I can't pull benchmarks for [Company Name] — the corporation's plan returned an unexpected peer-group dimension (
<dimension value>), so I don't know which peer-group bucket to query. This usually means compensation-service shipped a new dimension type the skill hasn't been updated for. Please reach out to the CTC team to confirm the corp's plan configuration."
Why this is a stop-not-guess: each dimension's bucket param accepts values from a different enum. Picking a default would either send a code value that's invalid for the chosen param (HTTP 400) or — worse — accidentally return data from the wrong peer-group dimension, which would mislead the user with numbers that don't match what they see in the product UI.
If the dimension is one of the three known values, continue to Step 4 above.
Single-job bulk: omit level to get every level for one job in one call (~17 rows, fits well under the gateway response budget).
Multi-job bulk (CSV across all functions): issue one fetch per job area in parallel — do not omit both job and level. The unfiltered query returns ~22 jobs × ~17 levels in a single payload that exceeds the 40K-char gateway budget and will be rejected with "response too large". Iterating per-job stays inside the budget and parallelizes cleanly.
The response shape is:
{
"benchmarks": [
{
"job": "ENGINEER", "ladder": "IC", "level": "SENIOR1",
"salary_benchmarks": {
"yearly_salary": {"low": "158000.00", "mid": "186000.00", "high": "214000.00"},
"percentiles": {"p25": "...", "p50": "...", "p75": "...", "p90": "..."},
"currency_code": "USD"
},
"equity_benchmarks": {
"as_fd_percentage": {"low": "...", "mid": "0.012600", "high": "..."},
"as_shares": {"low": "...", "mid": "7717.00", "high": "..."},
"as_notional_value": {"low": "...", "mid": "41000.00", "high": "..."},
"percentiles": {...},
"quantity": "FOUR_YEAR_GRANT"
},
"tcc_benchmarks": {
"yearly_tcc": {"low": "...", "mid": "210000.00", "high": "..."},
"percentiles": {...}
},
"geo_adjustment": {"label": "...", ...}
},
...
],
"count": N,
"benchmark_version": {"id": ..., "version_major": ..., "version_minor": ..., "created": "...", "description": "..."}
}
Three rating types — show only the percentile distribution (p25/p50/p75/p90) for each. The low/mid/high band fields are derived from the corp's pay-philosophy target percentile and a configured spread; they're noise for benchmark queries. Read the percentiles directly from the response.
Null percentile handling — no fallbacks allowed:
If a percentile field (p25, p50, p75, p90) is null or absent, render it as — in the table. Do not substitute yearly_salary.mid, yearly_salary.low, or any other band field. Do not infer, interpolate, or borrow values from adjacent fields. A — is correct and honest; a band value presented as a percentile is misleading.
Sparse benchmark version message: If some or all percentiles are null (older benchmark versions may only populate a subset), add a note below the table:
Some percentiles are unavailable — this corporation's plan uses an older benchmark version (v[X.Y], [Month YYYY]) with limited coverage. To see full percentile ranges, the plan can be updated to a current benchmark version.
Do not editorialize beyond this. Do not say "the data is incomplete" or suggest the data is wrong — it's accurate for that version, just limited.
Use the single-role chat reply format and CSV column structure spelled out in the CRITICAL block at the top of this file. Do not improvise different shapes.
For role-specific emphasis when there's space to call out a single number per rating:
salary_benchmarks.percentiles.p50 (median market salary) and equity_benchmarks.percentiles.p50.as_notional_value (median annual equity in $)tcc_benchmarks.percentiles.p50 (median total cash) since variable pay is a major part of sales comp; salary alone understates the comp pictureas_fd_percentage, as_shares, AND as_notional_value percentiles; users want all three (what % of company, how many shares, what's it worth)The order in which equity columns appear depends on the corp's peer group, surfaced by compensation:get:plan as peer_group.notional_available:
peer_group.notional_available |
Order |
|---|---|
true (≥ $500M post money) |
notional → FD% → shares |
false (all smaller peer groups) |
FD% → shares → notional |
Rationale: actual notional-value benchmarks are only available for valuations ≥ $500M. Below that, the notional is a derived figure, so FD% (the structural metric) leads. Apply this ordering to:
equity_fd_pct_* / equity_shares_* / equity_notional_* groupsIf peer_group is missing from the plan response (very old plans, mis-configured corps), fall back to the default order (FD% → shares → notional).
If a benchmarks array entry has empty salary_benchmarks, empty equity_benchmarks, AND empty tcc_benchmarks (i.e. only geo_adjustment populated), that's a genuine data coverage gap for that role/level/version. Flag it as a separate "no data" section and exclude the row from the data table — do not emit a zero-filled row.
If the user asks about the current benchmark version or wants to query against a different version, follow the workflow in ${CLAUDE_PLUGIN_ROOT}/skills/carta-compensation-benchmarks/references/benchmark-versions.md.
HARD STOP RULE:
is_subscribed: falseis FINAL. Do NOT callplans/orbenchmark/for that corp. Do NOT "try anyway to see what comes back". Do NOT rationalize ("local/test environments may still return data"). Thesubscription_statusresponse is authoritative — if the UI says no subscription, the API has no data, and you must respect that.
Compensation-service's plans/ and benchmark/ endpoints return 200 even for corporations that don't have an active CTC subscription — the response just has empty/null ratings. You cannot infer subscription status from those calls. That's why subscription_status exists.
Once you have resolved the corporation (Step 1), call compensation:get:subscription_status as Step 2 — before any plans/ or benchmark/ call, and before asking the user for a role (see Step 2 above). It returns {corporation_id, is_subscribed}. "First" here means first among the compensation calls, not before corp resolution — you still need a corporation_pk from Step 1 to make this call.
Single-corp query:
call_tool({"name": "compensation__get__subscription_status", "arguments": {"corporation_id": <id>}})is_subscribed is false:
call_tool({"name": "context_tools__get__profile", "arguments": {}}) and check whether corporations[] is non-empty. Treat a non-empty list as "has cap table access" — the endpoint already excludes NO_ACCESS roles, so presence is the signal. Do not match on the role label: those strings are raw and unnormalized ('Admin' and 'Administrator' both occur), so an allowlist would misclassify real admins. Do not test whether this specific corp is in the list — the list is capped server-side, so a large-portfolio admin can be a false negative.corporations[] is non-empty, tell the user:
"That's a Carta Total Compensation (CTC) question — CTC runs on Carta's private market salary and equity data. The data can be segmented by level, function, stage, and geography, directly in Claude. It's not active for your company yet. Reach out to your account team or request a demo to unlock it.
I can pull cap table data — grants, vesting, round history — in the meantime."
corporations[] is empty — a Fund-Admin-only user, or the corp came from a hand-typed numeric ID (Path 1) that they hold no cap-table role on — tell the user:
"That's a Carta Total Compensation (CTC) question — CTC runs on Carta's private market salary and equity data. The data can be segmented by level, function, stage, and geography, directly in Claude. It's not active for your firm yet. Reach out to your account team or request a demo to unlock it."
plans/, benchmark/, benchmark_versions, or any other compensation endpoint for this corp.is_subscribed is true → proceed with the benchmark workflow.Multi-corp / bulk query:
0. If any corp turns out to be unsubscribed, call context_tools__get__profile once for the whole batch and reuse the result. The cap-table-access branch is per-user, not per-corp — never re-fetch it inside the loop.
compensation:get:subscription_status for each corp up front. Partition them into subscribed and unsubscribed lists.plans/, benchmark/) only for corps in the subscribed list. Never query the API for corps in unsubscribed.subscription_status returns is_subscribed: false → agent thinks "let me try the benchmark anyway to see if local/test data exists" — NO. Stop and tell the user.A compensation:* read can fail for two unrelated reasons that must NOT be conflated, because they have different remediations:
You distinguish these — the MCP layer does not. The carta MCP gateway is a thin, domain-agnostic adapter: when a compensation command hits a 403 it surfaces the raw error, it does NOT probe subscription state or compose a friendly message. The authoritative signal for the distinction is
compensation:get:subscription_status: when it succeeds it returns{corporation_id, is_subscribed}—falseis no-subscription,truemeans a laterplan/benchmark403 is no-access. When it itself returns a 403, that 403 is the signal: the caller lacks a CTC role, so it's no-access. Step 2 already calls it up front — use its result (or its 403) to classify, per the table below.
How to classify a failure:
| What you observe | Meaning | What to do |
|---|---|---|
subscription_status → is_subscribed: false |
No subscription | Follow Subscription gating — send the demo-link message, STOP. Do not call plans/ or benchmark/. |
subscription_status → is_subscribed: true, but a later plan/benchmark read returns 403 |
No access (corp has CTC, you lack a role) | Send the no-access message below, STOP. Do NOT show a demo link — the corp already has CTC. |
subscription_status itself returns 403 |
No access (the 403 means you lack a CTC role on this corp) | Send the no-access message below, STOP. Treat a 403 on the status probe as no-access, not as "couldn't determine subscription". |
The no-access message (surface this verbatim):
"Your account doesn't have a CTC role for this corporation, contact a company admin for access"
This wording is deliberately neutral on subscription state: it's correct for both no-access rows above, including the subscription_status-403 case where the corp's subscription status is unknown (a 403 only establishes the caller lacks a CTC role, not that the corp has CTC). Do not assert that the corporation has Carta Total Compensation.
HARD STOP RULE: Both outcomes are FINAL. Do NOT re-authenticate, retry, or "try a different command to see if it works" — re-authentication issues a fresh token for the same user and does not grant a role they don't have. Do NOT generate a CSV/JSON file for that corp. No partial output.
Multi-corp / bulk query: call subscription_status for each corp up front; for corps that come back is_subscribed: true, a subsequent benchmark 403 means no-access. Partition and surface the two failure classes separately:
subscription_status result and compose the message.subscription_status 403 you don't even know the corp lacks CTC, so a demo link would be a guess.subscription_status as "subscription unknown, try the benchmark anyway". A 403 there means no-access — stop and send the no-access message.| Symptom | Cause | Tell user |
|---|---|---|
Rolematcher returns UNKNOWN for job_area |
Role description too vague or not in CTC taxonomy | "Could you clarify the role? For example: job area (Engineering, Sales), seniority level, and whether it's an IC or manager track." |
Rolematcher returns UNKNOWN for track |
Level is also UNKNOWN — no seniority signals present | "Is this an individual contributor (IC), manager, or executive role? This determines which benchmark track to use." |
| Benchmark response has no data for role/level (subscribed corp) | Data coverage gap — no snapshot for that exact slice | "No benchmark data is available for [role] at [level] in this benchmark version. Want to try a different level or focus?" |
compensation:get:subscription_status returns is_subscribed: false |
Corp doesn't have an active CTC subscription | See Subscription gating — stop and send the subscription message. |
compensation:get:subscription_status returns 403, OR is_subscribed: true but a plan/benchmark read returns 403 |
The current user lacks a CTC role on this corp | See Access gating — send the no-access message, stop. No demo link; do NOT re-authenticate (won't grant a missing role). |
After delivering benchmarks, offer these follow-up options:
Where the artifact panel was published, also mention the microapp — once per conversation, after the offers above:
Want to access CTC benchmark data without waiting for the artifact to rebuild in the future? Try building a microapp using
/carta-compensation-appin Claude Code!
Only on the artifact path: this points at a rebuild wait that only that path has, and the microapp is Claude Code-only, so on the Claude Code / Desktop / claude.ai chat path it would name a wait the user is not experiencing. Suggest it, never launch it — this skill has no build step, and the microapp is a separate opt-in that sweeps a whole corporation rather than one role.
原文・著作権は Anthropic および各プラグイン作者に帰属します。日本語訳は Claude API による自動翻訳です。