CtrlK
BlogDocsLog inGet started
Tessl Logo

carta-cap-table:issuance-import

Internal file-ingest sub-skill for carta-issuance. Turns an uploaded spreadsheet (.xlsx/.xlsm/.csv/.tsv) or document (.pdf/.docx) into the prefilled rows every carta-issuance surface consumes, for certificates, option grants and profits interest units. Not invocable directly — dispatched by carta-issuance Phase 0.25.

SKILL.md
Quality
Evals
Security

carta-cap-table:6.91.7

issuance-import

Reads a file the admin already has — typically the Carta importer template they downloaded from the app — and hands carta-issuance a prefilled set of rows. The file feeds the front of the existing pipeline; it does not add a path around any gate. Phase 1 still resolves stakeholders, Phase 1.5 still saves and validates, Phase 2 still reviews, Phase 3 is still the only mutate.

Do not invoke this skill directly. Dispatched by carta-issuance Phase 0.25.

References

FilePurpose
scripts/parse_upload.pyDoes the parsing. Header detection, column mapping, value coercion, security_type detection, and local name→id resolution. The model never hand-parses a workbook — a hand-read column is exactly how a quantity lands in an exercise-price field.
references/column-map.mdThe header synonyms and value picklists as documentation, for humans and for carta-modify-issuables to reuse rather than fork.

Two rules the script exists to enforce

  1. Unresolved is blank, never guessed. A Vesting Schedule or Share Class cell that doesn't match a real record exactly (case- and punctuation-insensitively) leaves the field unset and records an import_notes entry. There is deliberately no fuzzy matching: an almost-match issues genuinely wrong terms, and unlike a bad quantity the server cannot catch it. Do not add fuzzy fallback later without a product decision.
  2. Nothing is dropped silently. Unmapped columns, skipped rows, and uncoercible cells all land in _import_report.json, and per-field in each row's import_notes. A dropped Exercise Price column is a wrong-priced grant the user has no way to notice.

Usage

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-issuance/issuance-import/scripts/parse_upload.py" \
  --file "<path>" \
  [--sheet "<sheet name>"] \
  [--reference "$WORK/_reference.json"] \
  --out-dir "$WORK"
  • --sheet — only needed when the workbook has more than one importable sheet (the script exits 2 with CANDIDATES=[…]; ask which, then re-run).
  • --reference — the issuance_init payload written to a file: its raw section envelopes plus stakeholders. Pass it to get ids resolved in the same run. Omit it and every name comes back as an import_notes entry with the field left blank, which is correct but makes the admin re-pick by hand — so pass it whenever Phase 0.5's fetches have landed.

Output

FileContents
_import_knowns.json{security_type, rows, equity_plan_id?, batch_errors?} — its rows are the batch
_import_report.json{mode, source_file, sheet, row_count, unmapped_columns, skipped_rows, plan_name, batch_errors, notes_by_row}
_import_text.txtDocument mode only — extracted text

stdout is KEY=value lines (IMPORT_KNOWNS, IMPORT_REPORT, ROW_COUNT, SECURITY_TYPE). Exit 0 parsed, exit 2 nothing usable — the stderr line says which (ERROR: or AMBIGUOUS: plus CANDIDATES=).

Running the phase — step by step

carta-issuance Phase 0.25 hands you the whole ingest and hands back the rows the surface seeds from. These are its steps; nothing here is repeated in SKILL.md, so work through them in order and return to Phase 0.5 at the end.

Step 0 — Confirm you can actually run the parser

The parser is a local script, so this phase needs Bash(uv run *). Check your own tool surface for Bash before promising an import — no host guarantees it.

No Bash → do not hand-read the file. Reading a workbook by eye is the failure this whole phase exists to prevent, and offering it as a fallback would make the parser's guarantees optional. Say so and route to the feature built for this:

"I can't read spreadsheets in this session. Two options: import it directly in Carta's Drafts UI, which takes this same template — or paste the rows here as text and I'll set them up."

Pasted-as-text rows are fine: they arrive in the prompt, so the ordinary prompt-driven flow handles them with the user's own values in plain sight. That is different in kind from silently parsing a binary nobody can see.

Step 1 — Locate the file

Take the path straight from the prompt; a pasted ~/Downloads/… path is the norm. Only if the user said "the attached file" with no path, list the likely directories (ls -t ~/Downloads, ~/Desktop, the cwd) and look for a supported extension recently modified. Zero or several plausible matches → AskUserQuestion which one (allowed by engine rule 5's file carve-out). Bash(find *) is deliberately not granted to this skill — use ls.

Step 2 — Parse, before spending any round trip

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-issuance/issuance-import/scripts/parse_upload.py" \
  --file "<path>" --out-dir "$WORK"

This first run is deliberately without --reference: it costs nothing, and its output tells you the two things Phase 0.5's fetches need — the security_type and the names in the file. It prints SECURITY_TYPE=, ROW_COUNT=, and the paths it wrote.

  • Exit 2 with AMBIGUOUS: + CANDIDATES=[…] — the workbook has more than one importable sheet. AskUserQuestion which, then re-run with --sheet "<name>". Never merge two sheets into one batch (carta-issuance hard rule 1) and never pick for the user.
  • Exit 2 with ERROR: — nothing usable. Surface the message verbatim and fall back to the ordinary prompt-driven flow; do not guess at rows.

Reconcile security_type with the prompt. File and prompt disagreeing is a real fork → AskUserQuestion. The file wins only when the prompt never said.

Step 3 — Resolve the names to ids (chat surface only)

On the artifact, skip to Step 5. The page fetches its own reference data and resolves the file's free text itself, so a --reference pass here would be a round trip for a result it replaces.

On the chat surface, run Phase 0.5's issuance_init call exactly as documented, with security_type now supplied by the file, and pass stakeholder_names covering the names the file contains rather than the names the prompt named — that keeps the lookup bounded by the file's row count instead of roster size. Pass the whole list at once; a 40-row sheet resolved through a concatenated search= matches nobody and would create 40 duplicate stakeholders on a real cap table.

The account-setup gate still applies. Having a parsed file in hand is not a reason to push past it: a corp with no option-grant document set cannot issue one, whether the rows came from a spreadsheet or from the prompt. Stop where the gate says to stop — the parsed rows cost nothing and the file is still there afterwards.

Step 4 — Re-run the parser with that payload

uv run "…/parse_upload.py" --file "<path>" [--sheet "<name>"] \
  --reference "$WORK/_reference.json" --out-dir "$WORK"

--reference is the issuance_init payload you just wrote to a file. The parser matches the file's free text against it — vesting schedule, acceleration terms, share class (by name or prefix), legend (by code or name), document set, equity plan, and the roster — and writes _import_knowns.json. A cell matching nothing leaves its field unset with an import_notes entry; there is no fuzzy matching, and do not add any by hand.

Step 5 — Hand the rows back

_import_knowns.json holds {security_type, rows, equity_plan_id?, batch_errors?}. Its rows are the batch — return them and continue. The row count comes from the file, so engine rule 5's quantity-vs-headcount heuristic doesn't apply here (a 40-row sheet is unambiguously 40 rows). Surface batch_errors alongside the rows, and hold equity_plan_id for the first mutate only.

Each row may carry import_notes — [{field, raw_value, reason}], display-only. Whatever collects the terms must both report every note against the field it names and leave that field unanswered, so nothing can be submitted until the admin settles it. A marker alone is ignorable; the withheld default is what actually prevents a silent wrong issuance (chat-surface.md § 1). The artifact does this for itself from row.import_notes.

Strip import_notes before any mutate — same discipline as the review-only fields (Build the mutate payload). The server rejects unknown keys.

Step 6 — Say what happened, in one line, before the surface opens

Read _import_report.json and report totals — never silently drop a column or a row. A dropped Exercise Price column is a wrong-priced grant the user has no way to notice.

"Read 38 rows from Q3-grants.xlsx. State of Residency and Employee ID aren't fields this flow sets, and 3 values I couldn't match are flagged in the form — everything else is filled in. Review and submit when ready."

Name the skipped rows and the unmapped columns, not just their counts. Rows this skill can't issue (RSUs, SARs, CBUs, warrants, RSAs, convertibles) are skipped by the parser with a reason and need the Drafts UI — an admin who thinks a 40-row sheet issued 40 securities when it issued 37 has been misled. unmapped_columns holds the headers this skill has no field for; several — state_exemption, state_of_residency, employee_id, cost_center, job_title, salary, convertible_note — are dropped by design (chat-review.md), and "2 columns I couldn't map" leaves an admin who deliberately filled one in believing it landed. Say which: "State of Residency and Employee ID aren't fields this flow sets — add them on the stakeholder record in Carta."

Documents (.pdf / .docx)

Build the rows yourself per Document mode below, then continue from Step 3.


Spreadsheet mode

Deterministic end to end.

  • Header row — scanned across the first 6 rows, taking whichever maps the most known columns. Carta's importer template puts a paragraph of instructions in row 1 and the real headers in row 2, so assuming row 1 reads prose as column names. Needs ≥3 recognizable headers to count as a header row at all.
  • security_type — decided by header signature first (Exercise Price / Equity Plan Name / Document Set → option_grant; Share Class / Legend / Rule 144 Date → certificate), sheet name only as a tiebreak. A threshold column decides piu outright, before that comparison: the PIU template also carries an equity plan and a document set, which would otherwise tie it with the grant signals. Generic headers (Quantity, Email) are deliberately not signals.
  • Multiple importable sheets → AmbiguousInput, never a guess and never a merge. A batch is one security type (carta-issuance hard rule 1).
  • Values — dates to YYYY-MM-DD; numbers stripped of thousands separators, currency symbols and parenthesised negatives; Individual / Non Individual to INDIVIDUAL / NON-INDIVIDUAL (hyphen — matches lib/issuance_fields.py's STAKEHOLDER_KIND_CHOICES, not the Django enum); relationship, option type and grant reason matched exactly against payload-reference.md § Picklists.
  • Out-of-scope rows skipped, not coerced. An importer sheet can carry RSUs, SARs, CBUs, warrants, RSAs, convertibles. Those rows are skipped with a reason naming the Drafts UI — never reshaped into a grant of a different type.
  • Multiple equity plans in one sheet → a batch_errors entry. A draft set is locked to one plan server-side, so this has to surface before the terms are collected rather than failing at Phase 1.5.

Document mode (.pdf / .docx)

The script extracts text and stops. It does not build rows from prose.

A signed grant doc or board consent has no fixed layout, so turning it into rows is a judgement call — doing it in the script would mean guessing silently, which is rule 2 inverted. So: read _import_text.txt, write the rows yourself in this script's own row schema, and mark every field you filled this way with an import_notes entry carrying "confidence": "low". Every surface treats those as needs-confirmation, so a misread date is something the admin sees rather than something that issues.

If the text comes back empty the file is a scan — the script exits 2 saying so. Route the admin to OCR it or give the values directly; never infer values from a filename.

Take only what the document states. A grant agreement rarely names a vesting template by the company's own template name, so leave vesting_template_id unset rather than inferring it from prose like "vests monthly over four years" — that is the fuzzy match rule 1 forbids, done by hand.

Row schema

ROW_KEYS in parse_upload.py is the authoritative list. A stray key would survive into the save_drafts payload and be rejected server-side, so nothing outside it belongs on a row.

Two additions beyond that list:

KeyMeaning
row_keyPositional r0, r1, … — the key Phase 1.5 re-matches a row to its draft_pk by (save-validate-flow.md)
import_notes[{field, raw_value, reason}] — display-only. Never send to any mutate; scripts/serialize_drafts.py strips it at the payload boundary

Dates in a row are always ISO (YYYY-MM-DD) — that is what <input type="date"> accepts and reads back. Three of them (grant_expiration_date, vesting_start_date, rule_144_date) are CharFields the API only takes as MM/DD/YYYY; serialize_drafts.py converts them on the way out. Do not emit MM/DD/YYYY here — a date input reads back ISO and nothing else.

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.