CtrlK
BlogDocsLog inGet started
Tessl Logo

carta-cap-table:issuance-config

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.

Invalid
This skill can't be scored yet
Validation errors are blocking scoring. Review and fix them to unlock Quality, Impact and Security scores. See what needs fixing →
SKILL.md
Quality
Evals
Security

carta-cap-table:6.85.6

issuance-config panel

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.

References

FilePurpose
../../../lib/issuance_fields.pyEvery 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.pyBuilds 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.pyBuilds the whole Cowork form as one self-contained show_widget document. See Cowork form.
scripts/preview_config.pyDesign-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.yamlShared required + per-type optional substitutions; save + submit-watcher capabilities
references/template.htmlConfig panel — both field sets gated by data-sectype, shared sticky Review/Save footer
references/styles.cssInk-compliant panel styles (toggles, date/price inputs, legend attestation box)
references/cowork-template.htmlCowork form — same class contract, sendPrompt() submit, per-row and batch layouts
references/cowork-styles.cssCowork styles — design-system tokens only (see Cowork form for why the panel sheet can't be reused)
references/Inter-roman.var.woff2Inter variable font (panel only)
references/SangBleuVersailles-Regular-WebS.ttfSangBleu Versailles for corp name (panel only)

Cowork form

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.

Substitutions

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):

KeyValue
STAKEHOLDER_ROWSone 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_JSONJSON array of the corp roster ([{name,email,id,kind,event_relationship},…]) for name autocomplete + email/stakeholder-type/relationship auto-fill
BATCH_ERRORS_HTMLPanel-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.

Per-block field contracts

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.

GroupPer-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 notifiedGrant-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 notifiedGrant-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 relatedGrant-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 rowsprefix (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).

Server-error banners

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.

Stakeholder auto-populate

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.

Payload delivered on submit

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.

Back-to-edit round-trip

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.

Iterating on the UI

For design changes to the config panel (colors, spacing, layout, copy), you only touch three files — no Carta MCP and no full skill run:

FileWhat lives here
references/styles.cssAll panel styling (Ink tokens, toggles, inputs, legend box)
references/template.htmlStructure + the inline behavior JS
scripts/build_config.pyThe 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 --open

It 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.).

Repository
carta/plugins
Last updated
First committed

Is this your skill?

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.