Internal config panel sub-skill for carta-issuance. Renders a pre-flight configuration panel with one full key-value block per stakeholder — name, email, stakeholder type, relationship, quantity, and the whole type-specific field set (option type / exercise price / vesting / documents for option grants, or share class / price per share / legend / Rule 144 for certificates, plus issue date and board approval) — so a single batch can carry genuinely different terms per person. Not invocable directly — dispatched by carta-issuance Phase 0.5.
carta-cap-table:6.85.6
Pre-flight configuration panel for issuance. Replaces the AskUserQuestion chain
with a single interactive panel in the Claude Desktop side panel. The panel is a
repeater of one full key-value block per stakeholder — every field (name,
email, stakeholder type, relationship, quantity, and the whole type-specific
field set) lives inside that person's own block, so one batch can issue
genuinely different terms to different people. A "+ Add stakeholder" button
appends another block, pre-filled by copying the most-recently-added block's
non-personal terms forward. One template serves both security types: it
carries every field set, and the {{SECURITY_TYPE}} switch (option_grant |
certificate | piu) hides the rows whose data-sectype doesn't match and
selects the matching submit() payload. Issue date and Board approval are shared field
rows (not type-gated) inside every block.
Two footer buttons. Review posts action: "config_submit" — the parent
skill saves and validates (save_drafts + validate_drafts) before ever
rendering the review surface (engine.md's Phase
1.5); any
server error re-renders this same panel with a banner (see Server-error
banners, below), never silently moves on. Save
posts action: "save_only" — a lighter escape hatch, save_drafts only, no
validation, no panel re-render. Both gate on the exact same per-block
readiness check (missingFields()) — Save isn't a weaker bar.
Neither button is ever disabled. A click on an unready form always lands: it scrolls the first offending stakeholder into view, focuses the blocking field and states the reason beside the button, in the error colour. A disabled button whose reason sits in grey text elsewhere reads as a broken form, not as a gate.
Do not invoke this skill directly. Dispatched by carta-issuance Phase 0.5.
| File | Purpose |
|---|---|
../../../lib/issuance_fields.py | Every field builder, shared by both surfaces. The Cowork form collects the identical set, so one copy serves both and the two cannot drift. Also emits the so_type gate sets as JS (so_type_js_constants()) so the browser's idea of which types report to HMRC always matches Python's. |
scripts/build_config.py | Builds every dynamic block for the Code panel (toggle groups, grantee/holder rows, autocomplete roster) from the fetched data + prompt-derived knowns. The model never hand-authors panel HTML — doing so once shipped dead btn-card buttons and stamped a plan id where a document-set id belonged. |
scripts/build_cowork_form.py | Builds the whole Cowork form as one self-contained show_widget document. See Cowork form. |
scripts/preview_config.py | Design-iteration harness. Renders the panel to a standalone HTML file with committed sample data — no MCP, no skill run. See Iterating on the UI. |
references/artifact.yaml | Shared required + per-type optional substitutions; save + submit-watcher capabilities |
references/template.html | Config panel — both field sets gated by data-sectype, shared sticky Review/Save footer |
references/styles.css | Ink-compliant panel styles (toggles, date/price inputs, legend attestation box) |
references/cowork-template.html | Cowork form — same class contract, sendPrompt() submit, per-row and batch layouts |
references/cowork-styles.css | Cowork styles — design-system tokens only (see Cowork form for why the panel sheet can't be reused) |
references/Inter-roman.var.woff2 | Inter variable font (panel only) |
references/SangBleuVersailles-Regular-WebS.ttf | SangBleu Versailles for corp name (panel only) |
build_cowork_form.py emits one self-contained document for show_widget. Same fields,
same rows payload as the panel — only the submit path and the styling differ.
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-issuance/issuance-config/scripts/build_cowork_form.py" \
--security-type <option_grant|certificate|piu> \
--data "$WORK/_data.json" --knowns "$WORK/_knowns.json" \
--corp-name "<legal name>" --corp-id "<corporation_id>" \
--out "$WORK/form.html"
# → prints FORM=<path>. Pass the file's contents VERBATIM as show_widget's widget_code._data.json and _knowns.json are the same two files build_config.py takes — the
knowns table in code-adapter.md
is the contract for both. Add --no-minify while iterating on the markup.
Why the panel's styles.css is not reused. The widget host forbids four things it
does: hardcoded hex (invisible in dark mode), a background on the outer container (the
host paints it), 100vh/sticky positioning (the iframe sizes to content), and
@font-face (the CSP blocks the plugin origin). position: fixed is out too — it
collapses the iframe — so the submit state is an in-flow block, not the panel's modal
overlay. cowork-styles.css uses design-system tokens throughout and keys its
responsive rule off @container, not @media: a viewport query would track the user's
window rather than the form's own width.
Batch mode (cowork-adapter.md § Batch mode) activates on >3 rows whose terms are
all identical or unset, collapsing to shared-terms-once plus a name/email/quantity
table. knowns.batch_mode forces it either way. It is a rendering choice only: the
shared terms are expanded onto every row at submit, so the payload is indistinguishable
from the per-row layout's — including one row_key per person.
read_me is not needed. show_widget renders this document as-is, and the
interactive module carries no repeater guidance that would express this form — the
call is ~5k tokens and a round trip for nothing.
required (both types provide): CORP_NAME, CORP_ID, FLOW_TITLE
(Issue Option Grants | Issue Certificates | Issue Profits Interest Units —
verb-first, since this is a write operation), HEADER_SUB (7 grantees |
2 holders), SECURITY_TYPE (option_grant | certificate | piu).
optional (default ""; built by build_config.py):
| Key | Value |
|---|---|
STAKEHOLDER_ROWS | one full .stake-block per person from knowns.rows — every field (name, email, stakeholder type, relationship, quantity, and the whole type-specific field set) lives inside that block; one blank block when the prompt named no one. TODAY_ISO/CURRENCY/EXERCISE_PRICE_DEFAULT/PRICE_PER_SHARE_DEFAULT are no longer separate template substitutions — they're knowns scalar inputs build_config.py stamps into each block directly (see payload delivered on submit below for why: a token inside a generated fragment isn't re-substituted by render-panel's single text-replace pass) |
STAKEHOLDER_LIST_JSON | JSON array of the corp roster ([{name,email,id,kind,event_relationship},…]) for name autocomplete + email/stakeholder-type/relationship auto-fill |
BATCH_ERRORS_HTML | Panel-level red banner (above the Grantees/Holders list) for corp-/batch-level server errors from a Phase 1.5 validation round — built from knowns.batch_errors; "" (collapsed via CSS :empty) when clean. See Server-error banners. |
build_config.py normalizes the real MCP shape. The live cap_table:get:stakeholders
result uses full_name, never name — reading only name silently dropped every real
record (each one looked "nameless"), which emptied STAKEHOLDER_LIST_JSON against live
data even though every test fixture (built with a name key) kept passing. build_stakeholder_list()
reads s.get("name") or s.get("full_name") and always normalizes the output to a name key.
Every per-type option list (option type, vesting, documents, share class, legend, Rule 144)
that used to be its own top-level substitution now lives inside each block within
STAKEHOLDER_ROWS — there's no separate OPTION_TYPE_OPTIONS/VESTING_OPTIONS/etc. key
anymore, since each stakeholder's block needs its own independently-selected set.
{{SAVE_PORT}} is filled by render-panel.
build_config.py emits all of these — this is the spec it implements, not a
hand-authoring guide. The parent skill writes the fetched data + a knowns
object to disk and runs the script; the model never emits panel HTML. The rows
below document what the script produces (and what a reviewer should expect) for
each stakeholder block. Each button carries the attributes the template's JS
reads, and the row's own default is marked selected (falling back to the
batch-level knowns default when the row didn't specify its own value — see
carta-issuance engine.md). The
No vesting (grant) option is part of the script's output too.
| Group | Per-button HTML |
|---|---|
| Stakeholder type | <button class="toggle[ selected]" data-group="kind" data-value="INDIVIDUAL|NON-INDIVIDUAL" onclick="pick(this)">Individual|Non-individual</button> — two buttons; INDIVIDUAL selected by default. Auto-selected (but still clickable/editable) by template JS on an exact roster-name match. |
Type (so_type) | <div class="toggle-row"> of the corp's own resolved jurisdiction's 3 so_type buttons only, each <button class="toggle[ selected]" data-group="type" data-value="<so_type>" onclick="pickType(this)"><so_type></button> — US ISO/NSO/INTL, UK EMI/CSOP/Unapproved, or AU Startup Concessions/Non-Concessional/ZEPO, gated by knowns.jurisdiction (design feedback reversed an earlier "show all 9 across all 3 jurisdictions, grouped by jurisdiction" layout — a corp only ever issues one jurisdiction's types, so the other 6 read as clutter, not a genuine affordance). Mark this row's resolved type selected when it has one. pickType() (not the generic pick()) additionally re-syncs the HMRC/ATO conditional rows below for the newly-selected type. |
| Vesting | <select class="select-input block-vesting-select"> with one <option data-label="<name>"> per template plus the No vesting sentinel (value="__none__"). Grants: always shown, defaults to the 4yr/1yr cliff (or this row's own prior value) — vesting_template is always server-side (payload-reference.md). Certificates: also shown (opt-in server-side), but defaults to No vesting unless the row or the batch knowns default already names a real template — the opposite default from grants. |
| Documents | <button class="toggle[ selected]" data-group="docset" data-value="<set id>" data-label="<set name>" onclick="pick(this)"><name></button> — one per set; mark selected when only one set exists or this row already named one. |
| HMRC notified | Grant-only. A checkbox (.block-hmrc-notified, bound to is_hmrc_notified) + date input (.block-hmrc-notified-date, bound to hmrc_notified), tagged data-conditional="so_type_emi" — shown only when the row's so_type is EMI, hidden (and omitted from the submit payload) otherwise. pickType() toggles this row when the type selection changes. |
| ATO notified | Grant-only. A checkbox (.block-ato-notified, bound to is_ato_notified), tagged data-conditional="so_type_au" — shown only when so_type is Startup Concessions/Non-Concessional/ZEPO, hidden (and omitted) otherwise. |
| Employment related | Grant-only, required. A Yes/No toggle pair (data-group="employment-related", bound to employment_related), tagged data-conditional="so_type_employment_related" — shown only when so_type is Unapproved, hidden (and omitted) otherwise. A tri-state, unlike the two checkboxes above: neither button starts selected, and collectBlocks() sends null while none is picked, so an unanswered designation stays distinguishable from an explicit "No". This is what validate_drafts rejects — collecting it here is the whole point, since the failure would otherwise land after the draft set already exists. |
| Share class | <button class="toggle[ selected]" data-group="shareclass" data-value="<prefix>" data-label="<class name>" onclick="pick(this)">(<prefix>) <class name></button> — one per share class (button text carries the prefix, e.g. (CS) Common, so the user can tell classes apart without decoding it themselves; data-label stays the bare name). selected on the prompt-named/row's-own class; else the only class when there's just one; else nothing — two or more classes are never ranked, and the readiness gate holds Review until a human picks (see certificate-fields.md's Share-class reconciliation). Identical for a PIU's Unit class. |
| Legend | <button class="toggle[ selected]" data-group="legend" data-value="<legend id>" data-label="<legend name>" data-body="<full legal body, HTML-escaped>" onclick="pickLegend(this)"><legend name></button> — one per legend; mark the default/only/row's-own legend selected. Selecting one reveals its data-body in that block's attestation box. |
| Rule 144 reason | <select class="select-input block-rule144-reason"> with the 5-value rule_144_difference_reason enum (payload-reference.md); pre-selected with this row's own value. Lives inside .block-rule144-reason-wrap, shown/hidden by pickRule144() in lockstep with the Rule 144 date input — visible only when "Use a different date" is picked. Collected here, in the panel, instead of a separate post-submit AskUserQuestion (the prior design) — the reason is required at the same moment the date is, so there's no reason to make it a second round-trip. The Rule 144 date field itself carries the required=True marker (*) — design feedback that it read as optional without one, even though it's always collected (defaulting to the issue date). |
| Advanced fields (grant) | A collapsed <details class="advanced-fields"><summary>More fields (optional)</summary>, in order: custom_label, grant_reason (<select> — carta-web's own picklist, carta-modify-issuables/references/field-contract.md: New Hire, Merit, Promotion, Refresh, Corporate transaction, Relationship change, Retention, Advisor, Consultant, Board, Performance bonus, Boxcar grant — was free text, which silently invited server-rejected values), acceleration_template (moved in from its own top-level row; still tagged data-conditional="vesting", hidden when the block's own vesting is "No vesting"), early_exercise, auto_exercise_at_vest, is_flexible_issue_date, notes (moved in from the shared section). Collapsed is presentation only: collectBlocks() reads every one of these fields regardless of the accordion's open/closed state. (state_exemption/employee_id/cost_center/job_title/salary were dropped from the panel entirely — design feedback.) |
| Advanced fields (certificate) | Same accordion pattern, in order: acceleration_template (moved in, same conditional-on-vesting behavior), prefix_number, cash_paid, debt_canceled, notes (moved in). (convertible_note was dropped from the panel entirely — design feedback. returned_invested_capital was dropped too — it's LLC-only and no MCP command can confirm LLC status.) |
| PIU field rows | prefix (labelled Unit class), option_plan (optional, and never defaulted — an empty plan issues off the unit class), threshold_value and threshold_value_type (Unit / Overall only, labelled with the issuer's own noun from knowns.threshold_noun), issue_date, board_approval_date (optional, no pending state), vesting schedule + start, document_set_id, and corresponding_interest — rendered only when the selected unit class reports has_corresponding_interest. See piu-fields.md. |
| Advanced fields (PIU) | acceleration_template (conditional on vesting), prefix_number (Security number), cash_paid (Consideration price — UK growth shares only), notes. |
data-label is required on vesting / acceleration / documents / share-class / legend buttons
(read for the submit payload). data-body is required on legend buttons (the
attestation box and your record of what the user attested to).
Two additive, display-only inputs support engine.md's Phase 1.5 — neither is ever sent to a mutate:
row.row_key — stamped onto every block's data-row-key (build_stakeholder_blocks()
assigns a positional r<index> fallback only when a row doesn't already carry its own).
Read by collectBlocks() on every submit so the parent skill can re-match a resubmitted row
to its previously-saved draft_pk across a validation-error retry — never array
position, which desyncs the moment a block is added or removed mid-retry (an ordinary thing
to do while fixing an error on an otherwise-still-open panel). addStakeBlock() stamps a
fresh, non-colliding key ('new-' + Date.now() + …) on a clone — a clone is a new person,
not an edit to the source block's already-saved row.row.server_errors — a list of already-translated, already-human-readable message
strings for that specific stakeholder (e.g. "Quantity: Not enough shares in the option
plan"). build_stakeholder_block() renders them as a .block-error-banner (role="alert")
between the block head and the kv-table when present and non-empty — absent or empty renders
nothing (never an empty box). Messages are HTML-escaped but otherwise shown verbatim —
the parent skill translates payload keys before ever writing to this field (Voice &
defaults), this script never reinterprets a server message.knowns.batch_errors — the panel-level counterpart, for corp-/batch-level errors not
tied to any one stakeholder (missing signatory, a whole-issuance-level error). Renders as
.panel-error-banner into BATCH_ERRORS_HTML, above the Grantees/Holders list.The Name field is a text input with a custom typeahead dropdown against the
full roster (STAKEHOLDER_LIST_JSON) — select an existing stakeholder or type a
new name. This is template.html's own JS (renderSuggestions() /
selectSuggestion()), not a native HTML <datalist>: a <datalist> was tried
first and dropped — its suggestion popover doesn't render reliably inside Claude
Desktop's embedded webview, which made the "Search…" placeholder a lie (nothing
ever appeared). The dropdown filters the roster by substring match as the user
types, caps at 8 results, and closes on blur/Escape/outside-click. Focusing an empty Name
field also shows the first 8 roster entries (renderSuggestions()'s empty-query branch) —
design feedback that clicking into a blank field showed nothing until the user typed a
character, even when a roster clearly existed to pick from.
On an exact (case-insensitive) match — whether typed or picked from the dropdown — the block's Email, Stakeholder type, and Relationship fields auto-populate from the roster record. Fields stay editable, never locked — a prior version locked them (read-only / disabled) on the theory that an edit would be "ignored server-side," but that read as broken UI with no offsetting data-safety benefit: Phase 1 always uses the real cap-table record for an existing stakeholder regardless of what this panel shows, so locking the field doesn't protect anything the field's own value could threaten — it just looks like a bug. This mirrors the real Carta product's typeahead behavior in the drafts-v2 spreadsheet and simple-issuance forms.
This is the Code adapter's JSON action-request (machine-to-machine) — see
artifact-flow §3. On the Cowork path the
parent skill collects the same fields via a show_widget form, which returns the same
rows shape through sendPrompt() — see
cowork-adapter.md §1.
The payload carries action ("config_submit" for Review, "save_only" for Save —
identical rows shape either way; only action tells the parent skill which Phase 1.5
branch to run), security_type, and rows — every field lives inside each row now,
since each stakeholder's block is independently configured; there are no separate
batch-wide scalars alongside rows anymore. The parent skill takes rows as its working
set and looks each name up on the cap table; it does not re-ask for
quantity/stakeholder/terms in chat.
Every row also carries row_key (each block's stable identity — see Server-error
banners), notes, and acceleration_template (null when "No
acceleration" is selected) regardless of type — omitted from the examples below for
brevity, same as the other empty-string-default optional fields. Grant rows additionally carry custom_label,
early_exercise, auto_exercise_at_vest, is_flexible_issue_date,
grant_reason (all from the "More fields" accordion — grant_reason is a picklist value, not
free text), plus is_hmrc_notified/hmrc_notified
(only present when option_type is EMI), is_ato_notified (only present when
option_type is one of the AU types), and employment_related (only present when
option_type is Unapproved) — the template's collectBlocks() omits those keys
entirely for any other so_type, mirroring the panel data-conditional visibility, rather
than sending a stale value for a type that can't carry it. employment_related is the one
tri-state among them: it arrives as true, false, or null when still unanswered. Certificate rows additionally
carry vesting_template_id/vesting_start_date (same shape as grants, null when "No
vesting"), prefix_number, cash_paid, debt_canceled (accordion fields).
Option grant (two rows shown with genuinely different terms — the second demonstrates a block that diverged from the first via per-row edits or a copy-forward-then-changed value):
{
"action": "config_submit",
"security_type": "option_grant",
"corp_id": "2776",
"rows": [
{"name": "Jane Doe", "email": "", "quantity": "1000", "relationship": "Employee",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"option_type": "ISO", "exercise_price": "1.45",
"vesting_template_id": "94", "vesting_label": "4yr / 1yr cliff",
"vesting_start_date": "2026-06-11",
"document_set_id": "12", "document_set_label": "Standard option grant docs"},
{"name": "John Smith", "email": "", "quantity": "250", "relationship": "Consultant",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"option_type": "NSO", "exercise_price": "2.00",
"vesting_template_id": null, "vesting_label": "No vesting",
"vesting_start_date": null,
"document_set_id": "12", "document_set_label": "Standard option grant docs"}
]
}option_type is that row's selected so_type. exercise_price is a bare number; the parent skill hard-sets 0 for ZEPO regardless.vesting_template_id / vesting_start_date are null when No vesting is selected on that row (vesting_label is then "No vesting").stakeholder_kind (INDIVIDUAL | NON-INDIVIDUAL) is the row's Stakeholder-type toggle — auto-populated (but still editable) when the name matched an existing roster record; the parent skill only trusts it for a genuinely new stakeholder (an existing record's kind always wins regardless of what this toggle shows).Certificate:
{
"action": "config_submit",
"security_type": "certificate",
"corp_id": "2776",
"rows": [
{"name": "Jane Doe", "email": "", "quantity": "500", "relationship": "Employee",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"share_class_prefix": "CS", "share_class_label": "Common",
"price_per_share": "1.50",
"legend_id": "7", "legend_label": "Standard restrictive legend",
"rule_144_mode": "issue_date", "rule_144_date": null, "rule_144_reason": null}
]
}share_class_prefix is that row's selected class prefix (share_class_label its display name) — different rows can carry different classes.board_approval is approved_other (the panel doesn't distinguish "today" from "another date" — both are just a board-approval date; only pending is a distinct state) or pending (option-grant only — hidden for certificates, which always require a board approval date, and for PIUs, whose date is optional and simply cleared instead); board_approval_date is the chosen date, omitted when pending.rule_144_mode is issue_date (the default — rule_144_date and rule_144_reason are both null, and the parent skill stamps the issue date as the Rule 144 date) or other (rule_144_date carries the chosen YYYY-MM-DD; rule_144_reason carries the enum value picked from the panel's own reason <select> — the parent reformats the date to MM/DD/YYYY and stamps rule_144_reason as rule_144_difference_reason, no separate collection step needed).relationship in a row is the value the user selected in that block — the full
issue_date_relationship picklist (payload-reference.md),
always required for a new stakeholder (the template's missingFields() checks it, and
Review reports it rather than going quiet). In batch mode the relationship and
stakeholder-type controls ship hidden and are revealed for any row whose name misses the
roster — the roster answers for a match, and nothing answers for a miss. It can still arrive as "" for a
roster-matched row whose own record has no relationship on file — missingFields()
detects a roster match by re-running the same name lookup onStakeNameInput() uses (fields
are never locked/disabled — Stakeholder auto-populate, above —
so this can't be read off a field's disabled state), so an empty value there reflects the
existing record, not a skipped required field. The parent
skill stamps relationship as issue_date_relationship only for new stakeholders not found
on the cap table; an empty-string row still falls through to the roster lookup, then
AskUserQuestion as before.When the review panel's Back to edit returns the user here, the parent skill
reconstructs knowns.rows from $OUT_DIR/_review_rows.json (the Phase-1-resolved rows,
written before the review rendered) rather than re-deriving defaults — see carta-issuance
code-adapter.md's Back to edit. Every per-row key in the payload above
has a same-named or documented-mapping counterpart in a resolved row, so this is a
mechanical 1:1 copy, not a re-resolution.
For design changes to the config panel (colors, spacing, layout, copy), you only touch three files — no Carta MCP and no full skill run:
| File | What lives here |
|---|---|
references/styles.css | All panel styling (Ink tokens, toggles, inputs, legend box) |
references/template.html | Structure + the inline behavior JS |
scripts/build_config.py | The dynamic blocks (buttons, grantee rows, roster) |
Preview loop — edit a file, then:
uv run scripts/preview_config.py --open # renders both types, opens in browser
uv run scripts/preview_config.py --security-type certificate --openIt reproduces what render-panel does at runtime (runs build_config.py on the
committed sample fixtures, inlines styles.css, substitutes every {{TOKEN}}) and
writes preview_config_<type>.html. The Review and Save buttons are both inert
in preview (they POST to a dead port), so you can click through the form freely. Edit the
inline SAMPLE_* fixtures in preview_config.py to preview a different shape (more rows, a
longer legend, a UK jurisdiction, sample server_errors/batch_errors, etc.).
19590b8
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.