Skip to content

u2v-lp-intake

✍️ Writer · dispatcher-routed · writes to Attio

What it does

u2v-lp-intake turns a new LP lead into a clean Attio record. A teammate emails an LP's website (optionally with named contacts, or a .pdf/.xlsx roster of many LPs at once); the automation extracts each distinct lead, researches it, and dedupes-and-upserts the company plus its contacts into the Soft LPs U2V list — writing a fresh research note for each. It's the fundraising-side writer: it feeds the LP half of the brain.

The workflow

flowchart TD
    A["Email: an LP website / contacts / a roster file"] --> B{Input type?}
    B -->|.xlsx roster| X["Parse rows deterministically<br/>(no model call)"]
    B -->|text / PDF| L["Extract leads (1 model call)"]
    X --> C
    L --> C["For each lead (isolated):"]
    C --> D["Research the LP<br/>(Opus + extended thinking + web search)"]
    D --> E["Dedupe by domain → upsert company"]
    E --> F["Upsert each contact, link to company"]
    F --> G["Create-or-patch the Soft LPs list entry"]
    G --> H["Write a fresh 4-section note"]
    H --> I["Return a JSON summary of every lead"]

The per-lead loop lives in pipeline.py (_run_one_lead), and each lead is handled in isolation — one bad lead becomes a "failed" line in the summary, never an aborted run.

Key decisions

Spreadsheets skip the model entirely. An .xlsx roster is parsed deterministically with openpyxl and a header-alias map — no LLM call (ingest.py). A model is the wrong tool for reading a clean table; only messy text and PDFs go through extraction (Haiku for text, Sonnet for native PDF reading). This is the judgment-not-plumbing principle in miniature.

Dedupe on domain only — never on a fuzzy name. A company is matched (and upserted) by its domain and nothing else. Matching on name risks a false merge — folding two different LPs into one record — which is far worse than occasionally missing a dedup. Contacts match on email first, LinkedIn second.

Writes never regress existing data. When a company or list entry already exists, the automation fills only empty fields and leaves human-curated values (like the pipeline stage) untouched unless explicitly told to override. It writes carefully on top of a record a human may have already shaped.

Research runs for every lead, new or matched — its classification (investor category, sector) is needed to fill the list entry regardless of whether the company was already there. Inference is flagged, not hidden: when the category/sector was inferred, that surfaces in the reply, not silently in the record.

Least privilege — its own keys. This automation authenticates with its own narrowly-scoped Attio and Anthropic keys, separate from the shared ones the read-only tools use, and the code enforces that its own .env wins over any broader inherited key. So even though it can write, it can only touch the slice of Attio it needs. (See Design principles → secrets.)

Where things live

Path Role
src/u2v_lp_intake/pipeline.py Per-lead orchestration + the 4-section note builder
src/u2v_lp_intake/ingest.py Input handling: PDF → document block, .xlsx → deterministic rows
src/u2v_lp_intake/extract.py Text/PDF extraction (skips the model for spreadsheets)
src/u2v_lp_intake/research.py Per-lead LP research (Opus + thinking + web search)
src/u2v_lp_intake/attio.py Dedupe/upsert company + contacts + list entry + note
src/u2v_lp_intake/owners.py Static team roster (owners never resolved live)
stages/extract_email.md, stages/lp_research.md The two LLM rubrics
reference/attio_field_map.yaml The Attio write map
reference/owners.yaml The 6-person team roster
config.yaml Model routing, research/thinking settings, ingest size limits

Knobs a non-engineer can change in config.yaml: which model handles each path (routing), research web-search and thinking effort, and ingest limits (max body chars, max PDF size).

Status & how to run it

The Attio field map is live-confirmed against the real workspace (write shapes verified with a disposable create/delete smoke test) and the whole pipeline is offline-tested (101 tests). One gap remains before full production trust: a single real end-to-end email through the live CLI.

cd u2v-lp-intake
PYTHONPATH=src .venv/bin/python -m u2v_lp_intake.cli preflight
PYTHONPATH=src .venv/bin/python -m u2v_lp_intake.cli intake-email --owner benedikt@u2v.vc --body-file email.txt --json

Go deeper

The Attio write is the intricate part — company upsert, the person↔company relationship, and the non-regression rules on the list entry. Open the repo in Claude Code and ask "walk me through assert_list_entry and which fields it will and won't overwrite."