Carta Workhub — Build / Redeploy
Deploys the carta-workhub Cowork live artifact. It is assembled from source parts in
this skill's resources/ directory by scripts/build_artifact.py, which also substitutes
this session's Carta MCP server ID. You never need to read the assembled HTML.
Carta Workhub is the work surface: a live queue belongs on something you pin and keep open,
not inside a fund-data dashboard.
What the artifact does
Composer — "Ask Carta to do something" opens a box with preset tiles from
resources/carta-workhub.config.js; clicking one drops a labelled template into the textarea. Templates, not filled examples:
the blanks tell the sender what the team needs, and a prefilled amount invites sending
someone else's numbers.
The tiles mirror the app's Quick actions grid but carry no "Ask my Carta fund admin team
to…" preamble: that phrasing exists to route the chat picker, and here the text goes
straight to the team, so it would only be noise in what they read.
Then a second Review summary
step shows the draft verbatim before anything reaches fa:create:fund-admin-message. Send lives
only on that second step; Back to edit returns the text rather than an empty box.
Beta notice — a standing banner under the page title says the artifact is in beta and
that the list only covers requests sent through Claude. The listing sources below cannot see
a request raised by email or phone, so the gap is stated rather than left for the reader to
discover. It is the only body copy above the composer: the title and subtitle already say
what the page is for.
Open items — review infers the request type from detect in
resources/carta-workhub.config.js and checks each requires entry against the request, with
label-only template lines stripped so a blank template reads as unspecified. Anything unmet is
named in one line, with a single open box to add it. Nothing is forced: the box folds into the
request on send, and sending with it empty is fine. This is pattern matching, not inference —
the artifact has no model, so requirements are declared per preset. An unrecognised request
gets no checklist rather than a wrong one.
A requires regex must match what a sender writes, not what the template says. The
capital-call check looked for lps|investors|partners|class, which the template's own
"Split by LP class" line satisfied — so a request that named its call type instead
("Call Type: Pro-rata") was told it was missing information it had already given. That
requirement is now Call type, matching the four types Carta acts on: pro-rata, subsequent
close, bring investors in-line, hybrid. Change a template line and its requires entry
together, or the check drifts back into testing for the old wording.
Plan — Save as plan holds a drafted request without sending it. Plans show in their
own Planned group above the live queue, dashed and marked "Not sent", with Review and
send or Discard. Sending a plan runs the same confirm step, and the plan is dropped only
once the send succeeds. Carta has no unsent-draft state, so a plan lives in localStorage
and nowhere else, so a plan does not follow the user to another machine. Storage also throws on
an opaque origin (an artifact served from a data: URL), so a probe at first use decides which
of two truths every surface tells — saved on this computer, or kept for this session only. The
composer states the scope before Save as plan is pressed, and a tooltip on the Planned
heading carries the detail, so the caveat is available without giving it more page weight than
the work it describes. A durable, cross-device draft needs a server-side
command that does not exist yet: carta-mcp#1.
Sent state — a confirmation panel: "Your Carta team is on it", the notification promise, and
a line telling the sender they can reply or add detail in the thread while work is underway.
It deliberately does not show the workflow id — that is Carta's internal handle, and quoting
it at the sender implies it is how they follow up, when the thread is.
Request type — fa:create:fund-admin-message has no type field and the backend stamps
request_type: 'other', so a sent request would lose its category. The type is carried two
ways: as the message's first line, which is durable server-side and is the first thing the
team reads, and cached in localStorage against the workflow id. The row's own
additional_info carries the request as sent, so a title needs no thread read — only a row
with neither that nor a cached type falls back to reading its opening message (bounded to
FAR_HYDRATE_MAX).
The backend wraps message bodies. content_text and thread_metadata.message_snippet
both come back as " Additional Info:\n <indented body>". That preamble is
Carta's own formatting, so farUnwrap strips it and the indent everywhere text is read or
shown — without it every title read as "Additional Info:" and the thread showed the wrapper
to the customer.
Queue — grouped Tasks to complete (waiting on the customer), In progress, and a
collapsed Completed, sorted Newest or Oldest first. There is deliberately no
sort-by-status: the queue is grouped by status and rendered into fixed containers, so
ordering rows by group before re-partitioning them by group is a no-op — the two modes
produced byte-identical output. Cards in the same group that share a calendar day show the
time as well, or a re-sort looks like nothing happened. Cards carry no entity:
fa:create:fund-admin-message has no entity field, so entity is null on every row this
artifact creates, and the line was a placeholder on 100% of cards. Sorting by it went with it.
A real fix needs an entity_uuid on the create command — see docs/plans/carta-workhub-entity-uuid.md.
Card status reads
Sent / Working / Ready for you / Done — each an event the payload can prove.
There is deliberately no "received": nothing marks a read, so it would be a guess. Grouping reads status (an int; 2 and 3 are terminal and outrank
the pending actor) then last_task.template. Titles come from request_type, falling back
to thread_metadata.message_snippet.
fa:list:workflow takes only statuses and rejects any other param — the firm comes from
the session context. Its rows name no pending actor; that comes off tasks[].
Capital call review — a request-capital-activity workflow carrying an open
review-capital-activity (or review-capital-activity-changes) task opens the review panel
instead of the thread: the preparer's note, what is being called, when it is due,
how much of each commitment it consumes, and collapsible Investor allocations / Payment and
delivery. A second panel shows the notice each investor receives, as the rendered email and as
the real PDF. The footer carries Request changes and Approve and release, each behind
its own confirm step. Read from fa:get:capital-activity-review-summary,
fa:list:capital-activity-review-row, fa:get:capital-activity-partner-email-preview and
fa:get:capital-activity-notice-pdf-preview; written with fa:mutate:request-capital-activity-changes
and fa:mutate:approve-capital-activity. All of them ride the fetch and mutate tools
already in the grant, so adding a command never changes the publish call.
There is no build flag for this: the server decides which rows exist, so no review card
means the environment does not serve them. --ccr-fund-uuid / --ccr-activity-id seed one
card for a demo, and its panel still reads through the same commands.
The summary's embedded rows are the table's first paint. rows.results on the summary is
the unfiltered first page; seeding from it means the table is populated the moment the summary
lands, instead of reporting zero investors until the row walk returns. The walk then replaces
the seed wholesale rather than emptying it first. A count that is not yet known reads Totals,
never 0.
The panel is built to Capital Call Review v3 - Carta Tasks.dc.html, control for control.
The mock has 16 onClick handlers and 13 sc-if states; every one has a counterpart. When you
change this panel, re-derive that list from the mock rather than reading the diff — a control
that exists in the mock and not here does not announce itself.
The email preview follows carta-home's renderEmailPreview — the same command, the same
envelope-plus-scriptless-iframe shape, the same [/LINK_CARTA] caveat. Recipients key on
addr_type (TO / CC / BCC), not type. Keep the two behaviourally identical.
The Notice tab shows the real PDF, painted by a renderer the artifact carries. Read with
fa:get:capital-activity-notice-pdf-preview, which returns the document as a base64 data:
URI; pdf.js, vendored under resources/vendor/, paints it to canvas. The notice is never
redrawn from figures — a replica of a document drifts from the document.
Three measured facts hold that shape in place, and none of them is obvious:
- The browser's own viewer is blocked here.
<object>, <iframe src="data:"> and
<embed src="blob:"> were each tried against real PDF bytes inside a published artifact and
all three render nothing. A renderer in the page is the only way, so the vendored bundle is
load-bearing rather than a convenience.
- The bytes have to be in the response. The CSP blocks every external host, so neither the
authenticated Carta link nor a presigned S3 URL loads, and the page cannot redeem a document
token either.
- A binary blob would not survive the trip. Every Anthropic-managed surface rejects a blob
content block, so the command returns a
data: URI instead.
Each render costs. Carta renders the document through Prince or Carbone on every call and
stores it, so the panel fetches only the visible tab and only for the investor on screen —
never a walk down the picker. A measured notice is 2 pages and ~45 KB.
The activity link is read off the workflow row, not guessed: fund.uuid plus whichever of
capital_activity_id / object_id the row carries. A row with neither opens the panel to a
state that says so rather than to an empty review.
A release that fails ambiguously does not re-enable the button. server_unavailable and
upstream_error are not proof the release did not run, so the panel sends the reviewer to Carta
to check rather than inviting a second press.
Financial reporting tracker — one card per reporting period that needs the GP, opening the
Financial Reporting Tracker for that period: the banner, the combined filter-and-sort menu,
entity search, the period selector, and the six-column table with fund families as collapsible
rows. Every label and dot is produced from fa:get:reporting-status codes by the same rules
the backend's own page mappers use, so the panel and the page read identically. Every button
carries the backend's absolute href and opens Carta in a new tab; nothing is written from here.
The cards come from the tracker read, not from fa:list:workflow. On load the queue reads
the page's rolling window — the active quarter and the three before it, never earlier than
Q3 2023 — one fa:get:reporting-status call per period, in parallel. A period whose
rollup.needs_action is above zero gets a card in Tasks to complete titled
Financial reporting — Q2 2026, its second line counting the open items by column and its
footer naming the soonest deadline. A period the server refuses resolves to no card, so the
flag being off, or a firm the viewer cannot read, silently yields nothing rather than an error.
There is no build flag for this either: the read is gated server-side by
CARTA_MCP_FINANCIAL_REPORTING_TRACKER, so no card means the environment does not serve it.
--frt-seed-period "Q2 2026" forces one card for a demo; its panel still reads live.
The panel is wider than the review panel — min(1120px, 96vw) by min(760px, 90vh) —
because six table columns need it; below about 900px the table scrolls inside the panel, never
the page. The read returns the whole firm with no paging, so a firm past roughly 80 entities
would exceed carta-mcp's 40k reply cap and the period would read as failed; that is accepted
for now.
Thread view — the full conversation from fa:list:workflow-message, with a reply box
writing to fa:create:workflow-message. Carta's internal agent output is never surfaced.
Bodies render as paragraphs, taken from content_html — content_text is the same
message flattened, and preferring it threw away the structure the author wrote. Tags are
stripped either way, so nothing from the payload is ever inserted as HTML.
The opening message is a filled template, so it renders as a field grid: Label: value
lines become rows, a label with no value is dropped, and text before the first field is kept
unless the panel heading already says it.
Attribution is positional, not read from the payload. Every message comes back
author: {is_staff: true} — Carta's replies included — so is_staff labels everything
"Carta", and matching author.id to the signed-in user would label Carta's reply "You".
Index 0 is the request that opened the thread; a reply sent in this session is appended with
isStaff: false. Known gap: a reply sent in an earlier session shows as Carta.
Firm auto-detection — list_contexts resolves the active firm, then set_context pins it.
The firm name shows under the page title. list_contexts answers firm_name: "Unknown" for
some firms whose workflow rows carry the real name, so that literal is treated as no answer and
the queue's own firm.name wins. First real name set holds; a later blank cannot clear it.
A card links out only when the workflow carries workflow_cta_url. workflow_detail_url is a
/staff/ route, so it is never used — a customer cannot open it.
Listing sources, in order
fa:list:fund-admin-message — the customer-facing list. Not built yet in carta-mcp.
fa:list:workflow with template_type='request-generic' — staff only.
- Workflow ids this artifact recorded in
localStorage.
Path 3 cannot see requests raised by email or phone, so the UI says so rather than implying
the list is complete. Once path 1 lands, non-staff get a full list and the caveat disappears.
MCP tools required inside the artifact
The artifact resolves the bridge once with await claude.use("mcp"), then calls
mcp.callTool(CARTA_MCP_SERVER, "<tool>", args). CARTA_MCP_SERVER is the Carta
connector's display name — the {{CARTA_MCP_SERVER}} placeholder the build script
fills in. The runtime addresses connectors by display name only, never by a UUID.
Every tool below must appear in the publish call's capabilities.mcp grant, or the call
rejects with not_in_manifest:
list_contexts / set_context — resolve and pin the firm
fetch — the list and thread reads
mutate — sending a request, replying, uploading an attachment, and the review decision
welcome — re-initializes an expired MCP session
discover — asks whether this viewer may attach files at all
get_current_user — whose messages are whose in a thread
fa:create:document-content is staff-gated, so the composer probes discover and
stays text-only when the viewer may not attach. Publish without discover and every
viewer looks un-entitled, staff included.
Thread attribution compares each message's author against the viewer's own id, since
a staff sender's messages carry is_staff true exactly as Carta's do. Without
get_current_user it falls back to position, which labels a staff viewer's own
replies "Carta".
callTool rejects on tool failure rather than resolving with isError. The queue and
thread readers degrade one section while the rest of the page renders, so _mcp maps the
tool_error code back to an isError envelope and rethrows everything else — connector
codes (needs_reauth, server_not_connected) are page-level, not per-section.
Source layout — the artifact is BUILT, not hand-edited
Do NOT read or edit the assembled HTML. Edit the small source file for what you change.
| File |
What it holds |
resources/app/fund-admin-requests.js |
composer, queue, thread overlay — the whole feature |
resources/app/capital-call-review.js |
the capital call review panel and its notice sub-panel |
resources/app/financial-reporting-tracker.js |
the Financial Reporting Tracker cards and panel |
resources/carta-workhub.app.js |
shared helpers (_mcp, escHtml, showToast, trackWorkhub) plus firm resolution and boot |
resources/app/version-check.js |
update banner: reads the published version, compares, renders |
resources/carta-workhub.config.js |
TASK_PRESETS — the composer's preset tiles |
resources/carta-workhub.css |
styles (Ink tokens) |
resources/carta-workhub.template.html |
HTML skeleton + injection markers |
resources/carta-workhub.tracker.js |
inlined @carta/mcp-ui-tracker browser bundle |
resources/vendor/ |
pdf.js, vendored — pinned to the last UMD release for a reason its README gives |
../../.claude-plugin/skill-versions.json |
this skill's version + release headline |
carta-workhub.app.js duplicates a handful of helpers from carta-home.app.js on purpose:
the two artifacts ship independently, so neither may import from the other. Keep them
behaviourally identical.
Versioning
Same contract as carta-home-build, keyed to carta-workhub-build in
plugins/carta-investors/.claude-plugin/skill-versions.json. A deployed artifact is a frozen
copy, so change anything under resources/, bump the entry in the same PR — CI enforces it
via .forgejo/scripts/validate-artifact-version-bump.py.
In short, for anyone outside Carta reading this: any change under resources/ needs the
version in skill-versions.json raised in the same change, or users keep the old build with no
notice.
Patch is the default and raises no banner. Minor and major interrupt every user, so they
demand a fresh headline written for the person reading it. This skill's frontmatter carries no
version: on purpose: a second copy drifts silently.
Analytics
New interactive elements call trackWorkhub(action, elementId) at the top of the handler, with
ids as CartaWorkhub.<Area>.<Specific> (e.g. CartaWorkhub.Compose.Send). Skip sort clicks,
keystrokes, and dropdown changes.
Deploy steps
Step 0: Preflight
This is a live artifact — the published page calls Carta at runtime via
claude.use("mcp"), so it needs both the Artifact tool and a Carta connector in the
session. Check both before building; a page published without them renders an empty queue
for every viewer.
Gate A — the Artifact tool is available. If it is not, stop: there is nothing to
publish to.
Gate B — resolve the connector's display name. claude.ai connectors appear as
mcp__claude_ai_<connector>__<tool>. Find the one exposing list_contexts / fetch and
store its display name as CARTA_MCP_SERVER. Do not substitute a UUID or a prefixed
tool name — the runtime addresses connectors by display name and rejects anything else.
Display names legitimately contain spaces and parentheses, e.g. Carta (Preproduction),
so quote the value everywhere it is passed.
Step 1: Build
uv run "<SKILL_DIR>/scripts/build_artifact.py" --mcp-server "<CARTA_MCP_SERVER>" --out "<CWD>/carta-workhub.html"
Add --ccr-fund-uuid <fund_uuid> --ccr-activity-id <capital_activity_id> to seed one capital call
review card, or --frt-seed-period "Q2 2026" to seed one Financial Reporting Tracker card. Both
are for testing a panel before the queue would show its card; a normal build omits them.
Locate <SKILL_DIR> first. This exact form is what allowed-tools permits, so a
reworded one prompts for permission:
SKILL_DIR="$(dirname "$(dirname "$(find /sessions "$HOME" -type f -path '*/carta-workhub-build/scripts/build_artifact.py' 2>/dev/null | head -1)")")"
Fall back to ${CLAUDE_PLUGIN_ROOT}/skills/carta-workhub-build when that comes back empty.
The script prints the output path, version, and build id. It exits non-zero on any unresolved
marker or a missing registry entry.
Step 2: Find an already-published Carta Workhub
Artifact({action: "list", scope: "mine"})
Look for an artifact titled Carta Workhub. If one is there, keep its url — Step 3 passes
it so the page redeploys in place instead of claiming a second URL. If there is none, omit
url.
Step 3: Publish
One call either way. action defaults to "publish", so it is omitted below; url is the
only difference between a first publish and a redeploy.
Artifact({
file_path: "<CWD>/carta-workhub.html",
url: "<url from Step 2 — omit entirely on a first publish>",
title: "Carta Workhub",
description: "Work you have sent your Carta fund admin team, and what needs you.",
favicon: "🗂️",
label: "Redeployed from skill bundle",
capabilities: {
mcp: {
servers: [
{
server: "<CARTA_MCP_SERVER>",
tools: ["list_contexts", "set_context", "fetch", "mutate", "welcome", "discover",
"get_current_user"]
}
]
}
}
})
Anything the page calls that is missing from tools rejects with not_in_manifest.
mutate is what Send and Reply use, so leaving it out breaks both while the queue still
renders. Restate the whole capabilities object on every redeploy: a non-empty object
replaces the stored grant, so a tool you leave out is revoked. Keep favicon and title
stable — users find the tab by its icon.
Step 4: Confirm
Give the user the artifact's URL.
Carta Workhub is live. Anything waiting on you shows at the top under Tasks to complete.
The first open asks the viewer to consent to the Carta connector; until they accept, the
queue shows its no-connector state.
If something fails
- The queue reports
not_in_manifest — the publish call carried an incomplete
capabilities.mcp grant. Compare it against the tools list in Step 3 and republish with
every entry, passing the same url.
- No Attach files control, even for staff — most often
discover missing from the
grant, which reads exactly like being un-entitled. The console says which:
[far attach] availability probe failed is the manifest, not available to this viewer
is the staff gate on fa:create:document-content.
- Everything reports
server_not_connected or needs_reauth — the viewer has no
callable Carta connector under the name baked in at publish time, or their credentials
lapsed. Ask them to add or reconnect Carta in Settings → Connectors. If their connector's
display name differs from the one Step 0 resolved, republish with the right name.
- Publishing with a
url is refused — that artifact was shared with the user rather
than owned by them. Drop url and publish fresh.
Known gap
Snowplow UI events do not fire. resources/carta-workhub.tracker.js is a build artifact of
@carta/mcp-ui-tracker and probes cowork?.callMcpTool, which no longer resolves; hand-
patching a minified bundle would be overwritten by the next build:browser. Upstream needs
a claude.use("mcp") transport. test_vendored_tracker_still_carries_the_dead_cowork_transport
pins the gap so it fails once upstream ships and this caveat can be dropped.
1---2name: carta-workhub-build3description: Builds or rebuilds the Carta Workhub live artifact — a standalone Cowork view of the work a firm has sent its Carta fund admin team. Shows a request composer over a queue grouped into Tasks to complete (waiting on you), In progress (Carta is working), and a collapsed Completed, with a thread view for each request. The artifact auto-detects the active firm from the Carta MCP context — no hardcoded firm name needed. Use this skill whenever the user asks to "build the carta workhub artifact", "rebuild carta workhub", "set up carta workhub", "deploy carta workhub", "show my Carta workhub", "rebuild carta tasks", "show my Carta task board", or "pin my Carta requests".4---56<!-- carta:instrumentation-fallback -->7<IMPORTANT>8`_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:9`_instrumentation_v2: {"skills": ["<plugin>:<skill>", ...], "model": "<model-id>", "surface": "<value>"}`10`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.11`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.12</IMPORTANT>1314# Carta Workhub — Build / Redeploy1516Deploys the `carta-workhub` Cowork live artifact. It is **assembled** from source parts in17this skill's `resources/` directory by `scripts/build_artifact.py`, which also substitutes18this session's Carta MCP server ID. You never need to read the assembled HTML.1920Carta Workhub is the work surface: a live queue belongs on something you pin and keep open,21not inside a fund-data dashboard.2223## What the artifact does2425- **Composer** — "Ask Carta to do something" opens a box with preset tiles from26 `resources/carta-workhub.config.js`; clicking one drops a labelled template into the textarea. Templates, not filled examples:27 the blanks tell the sender what the team needs, and a prefilled amount invites sending28 someone else's numbers.29 The tiles mirror the app's Quick actions grid but carry no "Ask my Carta fund admin team30 to…" preamble: that phrasing exists to route the chat picker, and here the text goes31 straight to the team, so it would only be noise in what they read.32 Then a second **Review summary**33 step shows the draft verbatim before anything reaches `fa:create:fund-admin-message`. Send lives34 only on that second step; **Back to edit** returns the text rather than an empty box.35- **Beta notice** — a standing banner under the page title says the artifact is in beta and36 that the list only covers requests sent through Claude. The listing sources below cannot see37 a request raised by email or phone, so the gap is stated rather than left for the reader to38 discover. It is the only body copy above the composer: the title and subtitle already say39 what the page is for.40- **Open items** — review infers the request type from `detect` in41 `resources/carta-workhub.config.js` and checks each `requires` entry against the request, with42 label-only template lines stripped so a blank template reads as unspecified. Anything unmet is43 named in one line, with a single open box to add it. Nothing is forced: the box folds into the44 request on send, and sending with it empty is fine. This is pattern matching, not inference —45 the artifact has no model, so requirements are declared per preset. An unrecognised request46 gets no checklist rather than a wrong one.4748 **A `requires` regex must match what a sender writes, not what the template says.** The49 capital-call check looked for `lps|investors|partners|class`, which the template's own50 "Split by LP class" line satisfied — so a request that named its call type instead51 ("Call Type: Pro-rata") was told it was missing information it had already given. That52 requirement is now `Call type`, matching the four types Carta acts on: pro-rata, subsequent53 close, bring investors in-line, hybrid. Change a template line and its `requires` entry54 together, or the check drifts back into testing for the old wording.55- **Plan** — **Save as plan** holds a drafted request without sending it. Plans show in their56 own **Planned** group above the live queue, dashed and marked "Not sent", with **Review and57 send** or **Discard**. Sending a plan runs the same confirm step, and the plan is dropped only58 once the send succeeds. Carta has no unsent-draft state, so a plan lives in `localStorage`59 and nowhere else, so a plan does not follow the user to another machine. Storage also throws on60 an opaque origin (an artifact served from a `data:` URL), so a probe at first use decides which61 of two truths every surface tells — saved on this computer, or kept for this session only. The62 composer states the scope **before** Save as plan is pressed, and a tooltip on the Planned63 heading carries the detail, so the caveat is available without giving it more page weight than64 the work it describes. A durable, cross-device draft needs a server-side65 command that does not exist yet: carta-mcp#1.66- **Sent state** — a confirmation panel: "Your Carta team is on it", the notification promise, and67 a line telling the sender they can reply or add detail in the thread while work is underway.68 It deliberately does **not** show the workflow id — that is Carta's internal handle, and quoting69 it at the sender implies it is how they follow up, when the thread is.70- **Request type** — `fa:create:fund-admin-message` has no type field and the backend stamps71 `request_type: 'other'`, so a sent request would lose its category. The type is carried two72 ways: as the message's **first line**, which is durable server-side and is the first thing the73 team reads, and cached in `localStorage` against the workflow id. The row's own74 `additional_info` carries the request as sent, so a title needs no thread read — only a row75 with neither that nor a cached type falls back to reading its opening message (bounded to76 `FAR_HYDRATE_MAX`).7778 **The backend wraps message bodies.** `content_text` and `thread_metadata.message_snippet`79 both come back as `" Additional Info:\n <indented body>"`. That preamble is80 Carta's own formatting, so `farUnwrap` strips it and the indent everywhere text is read or81 shown — without it every title read as "Additional Info:" and the thread showed the wrapper82 to the customer.83- **Queue** — grouped **Tasks to complete** (waiting on the customer), **In progress**, and a84 collapsed **Completed**, sorted **Newest** or **Oldest** first. There is deliberately no85 sort-by-status: the queue is grouped by status and rendered into fixed containers, so86 ordering rows by group before re-partitioning them by group is a no-op — the two modes87 produced byte-identical output. Cards in the same group that share a calendar day show the88 time as well, or a re-sort looks like nothing happened. Cards carry no entity:89 `fa:create:fund-admin-message` has no entity field, so `entity` is `null` on every row this90 artifact creates, and the line was a placeholder on 100% of cards. Sorting by it went with it.91 A real fix needs an `entity_uuid` on the create command — see `docs/plans/carta-workhub-entity-uuid.md`.92 Card status reads93 **Sent** / **Working** / **Ready for you** / **Done** — each an event the payload can prove.94 There is deliberately no "received": nothing marks a read, so it would be a guess. Grouping reads `status` (an int; 2 and 3 are terminal and outrank95 the pending actor) then `last_task.template`. Titles come from `request_type`, falling back96 to `thread_metadata.message_snippet`.9798 **`fa:list:workflow` takes only `statuses` and rejects any other param** — the firm comes from99 the session context. Its rows name no pending actor; that comes off `tasks[]`.100- **Capital call review** — a `request-capital-activity` workflow carrying an open101 `review-capital-activity` (or `review-capital-activity-changes`) task opens the review panel102 instead of the thread: the preparer's note, what is being called, when it is due,103 how much of each commitment it consumes, and collapsible Investor allocations / Payment and104 delivery. A second panel shows the notice each investor receives, as the rendered email and as105 the real PDF. The footer carries **Request changes** and **Approve and release**, each behind106 its own confirm step. Read from `fa:get:capital-activity-review-summary`,107 `fa:list:capital-activity-review-row`, `fa:get:capital-activity-partner-email-preview` and108 `fa:get:capital-activity-notice-pdf-preview`; written with `fa:mutate:request-capital-activity-changes`109 and `fa:mutate:approve-capital-activity`. All of them ride the `fetch` and `mutate` tools110 already in the grant, so adding a command never changes the publish call.111112 There is no build flag for this: the server decides which rows exist, so no review card113 means the environment does not serve them. `--ccr-fund-uuid` / `--ccr-activity-id` seed one114 card for a demo, and its panel still reads through the same commands.115116 **The summary's embedded rows are the table's first paint.** `rows.results` on the summary is117 the unfiltered first page; seeding from it means the table is populated the moment the summary118 lands, instead of reporting zero investors until the row walk returns. The walk then replaces119 the seed wholesale rather than emptying it first. A count that is not yet known reads `Totals`,120 never `0`.121122 **The panel is built to `Capital Call Review v3 - Carta Tasks.dc.html`, control for control.**123 The mock has 16 `onClick` handlers and 13 `sc-if` states; every one has a counterpart. When you124 change this panel, re-derive that list from the mock rather than reading the diff — a control125 that exists in the mock and not here does not announce itself.126127 **The email preview follows `carta-home`'s `renderEmailPreview`** — the same command, the same128 envelope-plus-scriptless-iframe shape, the same `[/LINK_CARTA]` caveat. Recipients key on129 `addr_type` (`TO` / `CC` / `BCC`), not `type`. Keep the two behaviourally identical.130131 **The Notice tab shows the real PDF, painted by a renderer the artifact carries.** Read with132 `fa:get:capital-activity-notice-pdf-preview`, which returns the document as a base64 `data:`133 URI; pdf.js, vendored under `resources/vendor/`, paints it to canvas. The notice is never134 redrawn from figures — a replica of a document drifts from the document.135136 Three measured facts hold that shape in place, and none of them is obvious:137138 - **The browser's own viewer is blocked here.** `<object>`, `<iframe src="data:">` and139 `<embed src="blob:">` were each tried against real PDF bytes inside a published artifact and140 all three render nothing. A renderer in the page is the only way, so the vendored bundle is141 load-bearing rather than a convenience.142 - **The bytes have to be in the response.** The CSP blocks every external host, so neither the143 authenticated Carta link nor a presigned S3 URL loads, and the page cannot redeem a document144 token either.145 - **A binary blob would not survive the trip.** Every Anthropic-managed surface rejects a blob146 content block, so the command returns a `data:` URI instead.147148 **Each render costs.** Carta renders the document through Prince or Carbone on every call and149 stores it, so the panel fetches only the visible tab and only for the investor on screen —150 never a walk down the picker. A measured notice is 2 pages and ~45 KB.151152 **The activity link is read off the workflow row**, not guessed: `fund.uuid` plus whichever of153 `capital_activity_id` / `object_id` the row carries. A row with neither opens the panel to a154 state that says so rather than to an empty review.155156 A release that fails ambiguously does **not** re-enable the button. `server_unavailable` and157 `upstream_error` are not proof the release did not run, so the panel sends the reviewer to Carta158 to check rather than inviting a second press.159- **Financial reporting tracker** — one card per reporting period that needs the GP, opening the160 Financial Reporting Tracker for that period: the banner, the combined filter-and-sort menu,161 entity search, the period selector, and the six-column table with fund families as collapsible162 rows. Every label and dot is produced from `fa:get:reporting-status` codes by the same rules163 the backend's own page mappers use, so the panel and the page read identically. Every button164 carries the backend's absolute `href` and opens Carta in a new tab; nothing is written from here.165166 **The cards come from the tracker read, not from `fa:list:workflow`.** On load the queue reads167 the page's rolling window — the active quarter and the three before it, never earlier than168 Q3 2023 — one `fa:get:reporting-status` call per period, in parallel. A period whose169 `rollup.needs_action` is above zero gets a card in Tasks to complete titled170 `Financial reporting — Q2 2026`, its second line counting the open items by column and its171 footer naming the soonest deadline. A period the server refuses resolves to no card, so the172 flag being off, or a firm the viewer cannot read, silently yields nothing rather than an error.173174 There is no build flag for this either: the read is gated server-side by175 `CARTA_MCP_FINANCIAL_REPORTING_TRACKER`, so no card means the environment does not serve it.176 `--frt-seed-period "Q2 2026"` forces one card for a demo; its panel still reads live.177178 **The panel is wider than the review panel** — `min(1120px, 96vw)` by `min(760px, 90vh)` —179 because six table columns need it; below about 900px the table scrolls inside the panel, never180 the page. The read returns the whole firm with no paging, so a firm past roughly 80 entities181 would exceed carta-mcp's 40k reply cap and the period would read as failed; that is accepted182 for now.183- **Thread view** — the full conversation from `fa:list:workflow-message`, with a reply box184 writing to `fa:create:workflow-message`. Carta's internal agent output is never surfaced.185186 Bodies render as **paragraphs**, taken from `content_html` — `content_text` is the same187 message flattened, and preferring it threw away the structure the author wrote. Tags are188 stripped either way, so nothing from the payload is ever inserted as HTML.189190 The opening message is a filled template, so it renders as a **field grid**: `Label: value`191 lines become rows, a label with no value is dropped, and text before the first field is kept192 unless the panel heading already says it.193194 **Attribution is positional, not read from the payload.** Every message comes back195 `author: {is_staff: true}` — Carta's replies included — so `is_staff` labels everything196 "Carta", and matching `author.id` to the signed-in user would label Carta's reply "You".197 Index 0 is the request that opened the thread; a reply sent in this session is appended with198 `isStaff: false`. Known gap: a reply sent in an *earlier* session shows as Carta.199- **Firm auto-detection** — `list_contexts` resolves the active firm, then `set_context` pins it.200 The firm name shows under the page title. `list_contexts` answers `firm_name: "Unknown"` for201 some firms whose workflow rows carry the real name, so that literal is treated as no answer and202 the queue's own `firm.name` wins. First real name set holds; a later blank cannot clear it.203204A card links out only when the workflow carries `workflow_cta_url`. `workflow_detail_url` is a205`/staff/` route, so it is never used — a customer cannot open it.206207## Listing sources, in order2082091. `fa:list:fund-admin-message` — the customer-facing list. **Not built yet in carta-mcp.**2102. `fa:list:workflow` with `template_type='request-generic'` — staff only.2113. Workflow ids this artifact recorded in `localStorage`.212213Path 3 cannot see requests raised by email or phone, so the UI says so rather than implying214the list is complete. Once path 1 lands, non-staff get a full list and the caveat disappears.215216## MCP tools required inside the artifact217218The artifact resolves the bridge once with `await claude.use("mcp")`, then calls219`mcp.callTool(CARTA_MCP_SERVER, "<tool>", args)`. `CARTA_MCP_SERVER` is the Carta220connector's **display name** — the `{{CARTA_MCP_SERVER}}` placeholder the build script221fills in. The runtime addresses connectors by display name only, never by a UUID.222223Every tool below must appear in the publish call's `capabilities.mcp` grant, or the call224rejects with `not_in_manifest`:225226- `list_contexts` / `set_context` — resolve and pin the firm227- `fetch` — the list and thread reads228- `mutate` — sending a request, replying, uploading an attachment, and the review decision229- `welcome` — re-initializes an expired MCP session230- `discover` — asks whether this viewer may attach files at all231- `get_current_user` — whose messages are whose in a thread232233`fa:create:document-content` is staff-gated, so the composer probes `discover` and234stays text-only when the viewer may not attach. Publish without `discover` and every235viewer looks un-entitled, staff included.236237Thread attribution compares each message's author against the viewer's own id, since238a staff sender's messages carry `is_staff` true exactly as Carta's do. Without239`get_current_user` it falls back to position, which labels a staff viewer's own240replies "Carta".241242`callTool` **rejects** on tool failure rather than resolving with `isError`. The queue and243thread readers degrade one section while the rest of the page renders, so `_mcp` maps the244`tool_error` code back to an `isError` envelope and rethrows everything else — connector245codes (`needs_reauth`, `server_not_connected`) are page-level, not per-section.246247## Source layout — the artifact is BUILT, not hand-edited248249**Do NOT read or edit the assembled HTML.** Edit the small source file for what you change.250251| File | What it holds |252|------|---------------|253| `resources/app/fund-admin-requests.js` | composer, queue, thread overlay — the whole feature |254| `resources/app/capital-call-review.js` | the capital call review panel and its notice sub-panel |255| `resources/app/financial-reporting-tracker.js` | the Financial Reporting Tracker cards and panel |256| `resources/carta-workhub.app.js` | shared helpers (`_mcp`, `escHtml`, `showToast`, `trackWorkhub`) plus firm resolution and boot |257| `resources/app/version-check.js` | update banner: reads the published version, compares, renders |258| `resources/carta-workhub.config.js` | `TASK_PRESETS` — the composer's preset tiles |259| `resources/carta-workhub.css` | styles (Ink tokens) |260| `resources/carta-workhub.template.html` | HTML skeleton + injection markers |261| `resources/carta-workhub.tracker.js` | inlined `@carta/mcp-ui-tracker` browser bundle |262| `resources/vendor/` | pdf.js, vendored — pinned to the last UMD release for a reason its README gives |263| `../../.claude-plugin/skill-versions.json` | this skill's `version` + release `headline` |264265`carta-workhub.app.js` duplicates a handful of helpers from `carta-home.app.js` on purpose:266the two artifacts ship independently, so neither may import from the other. Keep them267behaviourally identical.268269## Versioning270271Same contract as `carta-home-build`, keyed to `carta-workhub-build` in272`plugins/carta-investors/.claude-plugin/skill-versions.json`. A deployed artifact is a frozen273copy, so **change anything under `resources/`, bump the entry in the same PR** — CI enforces it274via `.forgejo/scripts/validate-artifact-version-bump.py`.275276In short, for anyone outside Carta reading this: any change under `resources/` needs the277`version` in `skill-versions.json` raised in the same change, or users keep the old build with no278notice.279280Patch is the default and raises no banner. Minor and major interrupt every user, so they281demand a fresh headline written for the person reading it. This skill's frontmatter carries no282`version:` on purpose: a second copy drifts silently.283284## Analytics285286New interactive elements call `trackWorkhub(action, elementId)` at the top of the handler, with287ids as `CartaWorkhub.<Area>.<Specific>` (e.g. `CartaWorkhub.Compose.Send`). Skip sort clicks,288keystrokes, and dropdown changes.289290## Deploy steps291292### Step 0: Preflight293294This is a live artifact — the published page calls Carta at runtime via295`claude.use("mcp")`, so it needs both the `Artifact` tool and a Carta connector in the296session. Check both before building; a page published without them renders an empty queue297for every viewer.298299**Gate A — the `Artifact` tool is available.** If it is not, stop: there is nothing to300publish to.301302**Gate B — resolve the connector's display name.** claude.ai connectors appear as303`mcp__claude_ai_<connector>__<tool>`. Find the one exposing `list_contexts` / `fetch` and304store its **display name** as `CARTA_MCP_SERVER`. Do not substitute a UUID or a prefixed305tool name — the runtime addresses connectors by display name and rejects anything else.306Display names legitimately contain spaces and parentheses, e.g. `Carta (Preproduction)`,307so quote the value everywhere it is passed.308309### Step 1: Build310311```bash312uv run "<SKILL_DIR>/scripts/build_artifact.py" --mcp-server "<CARTA_MCP_SERVER>" --out "<CWD>/carta-workhub.html"313```314315Add `--ccr-fund-uuid <fund_uuid> --ccr-activity-id <capital_activity_id>` to seed one capital call316review card, or `--frt-seed-period "Q2 2026"` to seed one Financial Reporting Tracker card. Both317are for testing a panel before the queue would show its card; a normal build omits them.318319Locate `<SKILL_DIR>` first. This exact form is what `allowed-tools` permits, so a320reworded one prompts for permission:321322```bash323SKILL_DIR="$(dirname "$(dirname "$(find /sessions "$HOME" -type f -path '*/carta-workhub-build/scripts/build_artifact.py' 2>/dev/null | head -1)")")"324```325326Fall back to `${CLAUDE_PLUGIN_ROOT}/skills/carta-workhub-build` when that comes back empty.327328The script prints the output path, version, and build id. It exits non-zero on any unresolved329marker or a missing registry entry.330331### Step 2: Find an already-published Carta Workhub332333```334Artifact({action: "list", scope: "mine"})335```336337Look for an artifact titled **Carta Workhub**. If one is there, keep its `url` — Step 3 passes338it so the page redeploys in place instead of claiming a second URL. If there is none, omit339`url`.340341### Step 3: Publish342343One call either way. `action` defaults to `"publish"`, so it is omitted below; `url` is the344only difference between a first publish and a redeploy.345346```347Artifact({348 file_path: "<CWD>/carta-workhub.html",349 url: "<url from Step 2 — omit entirely on a first publish>",350 title: "Carta Workhub",351 description: "Work you have sent your Carta fund admin team, and what needs you.",352 favicon: "🗂️",353 label: "Redeployed from skill bundle",354 capabilities: {355 mcp: {356 servers: [357 {358 server: "<CARTA_MCP_SERVER>",359 tools: ["list_contexts", "set_context", "fetch", "mutate", "welcome", "discover",360 "get_current_user"]361 }362 ]363 }364 }365})366```367368> Anything the page calls that is missing from `tools` rejects with `not_in_manifest`.369> `mutate` is what Send and Reply use, so leaving it out breaks both while the queue still370> renders. Restate the whole `capabilities` object on every redeploy: a non-empty object371> replaces the stored grant, so a tool you leave out is revoked. Keep `favicon` and `title`372> stable — users find the tab by its icon.373374### Step 4: Confirm375376Give the user the artifact's URL.377378> Carta Workhub is live. Anything waiting on you shows at the top under **Tasks to complete**.379380The first open asks the viewer to consent to the Carta connector; until they accept, the381queue shows its no-connector state.382383## If something fails384385- **The queue reports `not_in_manifest`** — the publish call carried an incomplete386 `capabilities.mcp` grant. Compare it against the `tools` list in Step 3 and republish with387 every entry, passing the same `url`.388- **No Attach files control, even for staff** — most often `discover` missing from the389 grant, which reads exactly like being un-entitled. The console says which:390 `[far attach] availability probe failed` is the manifest, `not available to this viewer`391 is the staff gate on `fa:create:document-content`.392- **Everything reports `server_not_connected` or `needs_reauth`** — the viewer has no393 callable Carta connector under the name baked in at publish time, or their credentials394 lapsed. Ask them to add or reconnect Carta in Settings → Connectors. If their connector's395 display name differs from the one Step 0 resolved, republish with the right name.396- **Publishing with a `url` is refused** — that artifact was shared with the user rather397 than owned by them. Drop `url` and publish fresh.398399## Known gap400401Snowplow UI events do not fire. `resources/carta-workhub.tracker.js` is a build artifact of402`@carta/mcp-ui-tracker` and probes `cowork?.callMcpTool`, which no longer resolves; hand-403patching a minified bundle would be overwritten by the next `build:browser`. Upstream needs404a `claude.use("mcp")` transport. `test_vendored_tracker_still_carries_the_dead_cowork_transport`405pins the gap so it fails once upstream ships and this caveat can be dropped.