Skip to content
Back to skills

Issuance Import

ASecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
ai-agentsgobashdjangoapisecuritydocumentation

Works with

  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add gutsofpratik/OpenClaude-Skills --skill issuance-import --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Issuance Import?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Issuance Import
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gutsofpratik-issuance-import/badge)](https://www.skillsdirectory.com/skills/gutsofpratik-issuance-import)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: carta-cap-table:issuance-import
description: >-
  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.
owner: carta-cap-table maintainers (#cap-table-eng)
allowed-tools: []
---

<!-- carta:plugin-version -->
<carta-plugin>carta-cap-table:6.96.0</carta-plugin>

# 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/engine.md#phase-025--ingest-an-uploaded-file).

## References

| File | Purpose |
|---|---|
| `scripts/parse_upload.py` | **Does 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.md` | The 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

```bash
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

| File | Contents |
|---|---|
| `_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.txt` | Document 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](../references/engine.md#phase-025--ingest-an-uploaded-file) 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](../references/engine.md#phase-05--configure-the-issuance) 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

```bash
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](../references/engine.md#phase-05--configure-the-issuance)'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](../references/engine.md#account-setup-gate-option-grant-and-piu) 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

```bash
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](../references/chat-surface.md#1-collect--one-batch-and-only-what-is-genuinely-open)).
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](../references/engine.md#build-the-mutate-payload-from-your-phase-1-resolved-rows)).
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](../references/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](#document-mode-pdf--docx) 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](../references/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:

| Key | Meaning |
|---|---|
| `row_key` | Positional `r0`, `r1`, … — the key Phase 1.5 re-matches a row to its `draft_pk` by ([save-validate-flow.md](../references/save-validate-flow.md#draft-state-bookkeeping)) |
| `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 `CharField`s 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.

Files in this skill

  • SKILL.md14.6 KB
  • references/column-map.md11.5 KB
  • scripts/parse_upload.py45.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.