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."