fundgraph
README.md
# fein
**The fund graph for venture capital — an open-source agentic data layer for investment teams.** *(formerly `fundgraph`; the old CLI name, `FUNDGRAPH_*` environment variables, and data directories keep working)* An entity-resolved knowledge graph over your fund's scattered data — email, calendar, meeting notes, docs, CRM — with a relationship-intelligence dashboard and a single MCP endpoint for Claude, ChatGPT, or Cursor.

Agents can't operate over millions of documents by guessing with vector search: they fetch the wrong things and don't know what exists. fein gives them a deterministic **map of reality** instead — who exists, who knows whom, and how strongly — so retrieval is structured graph traversal, not similarity roulette.
```
systems of record fein consumers
───────────────── ────────────────────────── ──────────────────────
gmail ─┐ ┌─ metadata layer web dashboard
calendar ─┤ ingest │ (what was ingested, Claude / ChatGPT / Cursor
drive ─┼────────▶ │ when, who's mentioned) via one MCP endpoint
granola ─┤ ├─ entity resolution
crm ─┤ │ (blocking → candidates →
mbox/ics/csv │ matching → human review)
│ └─ knowledge graph
│ (people + orgs, weighted edges,
│ warm-path traversal)
```
## Quickstart
Requires Node 20+. No database setup — embedded Postgres ([PGlite](https://pglite.dev)) under `./data/`; set `DATABASE_URL` to use real Postgres.
```bash
npm install
npm start # → http://localhost:4321
```
First run shows onboarding: load the bundled (fictional) sample dataset with one click — it seeds the whole demo world, including a two-member team with a private layer — or drop in your own export. The dashboard also serves the [MCP endpoint](#mcp--agents-on-the-graph) at `/mcp`, so agents and the UI share one process. Embedded mode is **single-process** (a lockfile enforces this): stop the web server before running CLI ingests, or use `DATABASE_URL` to run several processes.
## Deploying for a team
`docker compose up -d` with one env var is a production deploy: token-gated
dashboard + MCP endpoint, data in a volume, fail-closed if the token is
missing. [DEPLOY.md](DEPLOY.md) is the client-onboarding runbook — TLS,
connecting agents, backups, Postgres mode, upgrades.
## Ask — a question box over the graph

`/ask` is for the people at a firm who will never open a dashboard: one
input, type a question, get an answer. Claude reads the graph through the
same MCP tools an external agent gets (an in-process client on an in-memory
transport), so the answer on the page and the answer in Claude Code come
from identical code. Tool calls show as one-line receipts under the answer,
lists come back as tables, and "as …" binds the question to a member's
private layer.
```bash
export ANTHROPIC_API_KEY=... # or `ant auth login`
FEIN_FIRM="Ridgeline Capital" npm start # → http://localhost:4321/ask
```
**On a subscription instead of an API key:** set `CLAUDE_CODE_OAUTH_TOKEN`
(from `claude setup-token`) and leave `ANTHROPIC_API_KEY` unset. Ask then
runs through the Claude Agent SDK — Claude Code as a library, coding tools
off, graph tools on — and usage comes out of the subscription. Set
`FEIN_HOME=ask` to make the question box the front door and move the
dashboard to `/dashboard`.
Agents and scripts can use the same endpoint: `POST /api/ask` with
`{"messages":[{"role":"user","content":"who came to the most events?"}]}`
returns server-sent events (`text` deltas, `tool` / `tool_result` receipts,
`done`). `FEIN_ASK_MODEL` and `FEIN_ASK_EFFORT` tune cost against depth.
## The dashboard
| | |
|---|---|
|  |  |
- **Explore** — search, click a node, get a brief: strongest relationships *with the signals behind each score* ("3 meetings, 2 emails, 1 co-authored doc"), recent shared documents, one-click Markdown export.
- **Radar** — which relationships need attention now, judged against each pair's own learned cadence.
- **Warm path** — the best route to an introduction, maximizing end-to-end relationship strength, with introducers ranked by their *weaker* leg.
- **Reviews** — matches scoring between the review floor and the auto-merge threshold (default 0.70–0.95, tunable in Settings) wait for a human; the system never merges identities on a guess. Decisions are audited and survive rebuilds.
- **Data** — drag-and-drop ingestion, live connectors, team members and their privacy layers, per-source breakdown, audit trail.
- **Settings** — what "a strong relationship" means differs by firm: signal weights, recency half-life, and saturation are editable live; saving rebuilds the graph instantly.
## Ingesting your data
| Source | Command | Setup needed |
|---|---|---|
| Gmail export (Takeout) | `fein ingest export.mbox` | none — streamed, so multi-GB archives are fine |
| Calendar export | `fein ingest calendar.ics` | none |
| Contacts (Google Contacts, Attio, Affinity, any CSV) | `fein ingest contacts.csv` | none |
| LinkedIn connections export | `fein ingest Connections.csv` | none — auto-detected; "Connected On" becomes timing signal |
| Granola (macOS) | `fein ingest-granola` | none — reads the local cache |
| **Attio workspace (live)** | `fein ingest-attio` | `ATTIO_API_KEY` — see below |
| **Affinity workspace (live)** | `fein ingest-affinity` | `AFFINITY_API_KEY` — key from Affinity **Settings → API** |
| Live Gmail/Calendar/Drive via [gog](https://github.com/steipete/gogcli) | `fein ingest-gog gmail` | gog already authenticated (local, or remote via `FEIN_GOG_SSH=user@host`) |
| Live Gmail/Calendar/Drive via Google APIs | `fein ingest-google gmail` | a Desktop OAuth client JSON in `GOOGLE_OAUTH_CREDENTIALS` |
### From a Google Workspace / Takeout export
Works on any account, including one you no longer actively use — you only need
to be able to sign in once.
1. At [takeout.google.com](https://takeout.google.com) (signed in as that
account) choose **Mail**, **Calendar**, and **Contacts**. For Mail, use
"All Mail data included" or select specific labels; export as `.zip`.
2. Unzip. You'll get `Takeout/Mail/All mail Including Spam and Trash.mbox`,
`Takeout/Calendar/*.ics`, and `Takeout/Contacts/contacts.csv`.
3. Ingest — order doesn't matter, entity resolution links them:
```bash
fein ingest "Takeout/Mail/All mail Including Spam and Trash.mbox"
for f in Takeout/Calendar/*.ics; do fein ingest "$f"; done
fein ingest Takeout/Contacts/contacts.csv
fein sync
```
The mbox is streamed and ingested in batches, so archive size is not bounded by
memory (measured ~420 messages/sec — a 100k-message account takes a few
minutes). Contacts exports use Google's own column names
(`E-mail 1 - Value`, `Organization Name`, split first/last), which the CSV
adapter handles directly; multiple addresses on one contact become one entity.
### From an Attio workspace

**In the dashboard** (no terminal): open the **Data** tab → *Attio workspace* →
paste your access token → **Connect & sync**. The key is verified against Attio
before anything is stored, the first pull runs immediately, and the panel then
shows the workspace, last sync, and a **Sync now** button for later refreshes.
Or from the CLI: `export ATTIO_API_KEY=... && fein ingest-attio && fein sync`.
To create the token: in Attio go to **Workspace settings → Developers → Create
an integration**, and grant read scopes for `record` and `object_configuration`
(add `note` to include notes).
A key pasted into the dashboard is stored in your local database and is
**write-only across the API** — no endpoint ever returns it, status shows a
masked hint (`····1234`) only, and it never reaches the audit log. It is stored
in plain text at the same trust level as the graph itself, so for shared or
server deployments prefer the `ATTIO_API_KEY` environment variable (which the
dashboard will detect and use without storing anything). **Disconnect** deletes
the stored key and leaves already-ingested data in place.
Pulls people, companies, notes, and [event lists](#events--guest-lists-as-relationship-evidence).
A person's linked company becomes their org hint, and all of a contact's addresses are attached to one entity — so an Attio
contact and their emails in Gmail resolve to the same person. Pass
`--no-notes` to skip notes (or if your token lacks the scope, notes are skipped
with a warning rather than failing the pull).
Then `fein sync` (resolve + rebuild edges).
**Affinity works the same way**: its card sits next to Attio's on the Data
tab (paste a key from Affinity **Settings → API**), or
`export AFFINITY_API_KEY=... && fein ingest-affinity && fein sync`. Same
write-only key handling, same privacy stance — people, organizations, and
note *participants*; note bodies are never read.
**What gets read:** live connectors (Granola, gog, Google APIs, Attio people/companies) read metadata and participant identities only. File exports (`.mbox`, `.ics`, `.csv` notes, `.jsonl`) also capture a size-capped plain-text **body** per document — stored locally in your database and mined only when you explicitly run [unstructured extraction](#unstructured-extraction). Set `FEIN_NO_BODIES=1` to skip body capture entirely and keep the old metadata-only behavior.
Adapters emit a common JSONL shape (see `sample/seed.jsonl`); to add a source, emit that shape and `fein ingest file.jsonl`. Ingestion is idempotent: re-ingesting updates in place, and review history is preserved.
## Events — guest lists as relationship evidence

A firm that runs events keeps one Attio list per event and one entry per
guest, with the guest's journey in the list's columns: invited, RSVP'd,
declined, attended. The Attio connector reads those lists too (any list
whose name ends in a date — `Cannes Closing Set · Jun 25, 2026` — plus any
pinned with `ATTIO_EVENT_DATES`) and turns every contacted guest into a
dated **touch** between the firm and that person:
| tier | kind | default weight | what it means |
|---|---|---|---|
| attended | `event` | 2 | they were in the room |
| RSVP'd yes | `rsvp` | 1.2 | they said yes |
| declined / invited | `invite` | 0.6 | the invitation went out |
| in the room together | `cohort` | 0.5 | guests of the same (small) event |
The tier is decided by whichever attribute reads like a guest's journey —
`attended`, `checked_in`, `rsvp`, `rsvp_status`, `stage`, `invite_sent`,
`gatsby_accepted` and so on — and every touch carries its receipt
(`rsvp_status=Accepted`). Review and priority columns never count. A past
event's guest list is a list of people who were invited, so membership
alone counts once the date has passed; on a future event it is a draft and
produces nothing until the invitation is actually sent.
Configuration, in the environment so the scheduler and the CLI agree:
```bash
ATTIO_EVENT_HOST="Jess Webber <jess@example.com>" # the firm-side person on every touch
ATTIO_EVENT_HOST_MAP='{"joe":"Joe Marchese <joe@example.com>"}' # "added by: Human - Joe" → Joe
ATTIO_EVENT_DATES='{"summit_2026":"2026-04-07"}' # lists whose names carry no date
ATTIO_FIRM_PATTERN='^(human|hv)\b' # "added by" values that mean the firm itself
```
"Invited by: Rich Greenfield" puts Rich on the touch too (he brought the
guest — that is who knows whom); "Added by: Publicis" records the partner
organization. The cohort document per event links the people who were
actually in the room, and the participant cap keeps a 600-person party from
becoming 180,000 edges while a 30-person dinner still counts.
What you get back:
```bash
fein events # every event with attended / RSVP'd / declined / invited counts
fein events "Cannes closing" # who was contacted about one event, by tier, with who brought them
fein history "Alex Rivera" # one person's event history + show rate
fein league most_attended # the loyal — also never_attended (the over-invited),
fein league lapsed --since 2026-01-01 # most_invited, lapsed, best_show_rate
```
Agents get the same as `list_events`, `event_guests`, `event_history` and
`guest_league`; `entity_brief` and `meeting_prep` carry an `events` block
when the person has one. Strength and radar simply see the touches: the
firm's relationship with a guest grows with every event they show up to and
cools when they stop, and a guest's own graph links them to the people they
sat next to.
## Fixing what resolution missed
Resolution is deliberately conservative — conflicting evidence queues for review
rather than merging — so real data always leaves a few duplicates (a work
address and a personal one for the same person, resolved apart). Merge them:
```bash
fein merge "Alex Rivera" "alex@northgate.io" # keep the first, absorb the second
fein unmerge "alex@northgate.io" # reversible
fein merges # what's been merged
```
Or in the dashboard: open a profile → **Merge a duplicate…**. Documents,
relationships, and addresses move to the survivor; the loser is kept as a
tombstone rather than deleted, so the merge stays reversible and unmerging gives
back *exactly* what the merge took (leaving an address behind would misroute
future mentions). Like review decisions, merges are human input — they're
recorded and **replayed after a full `reresolve`** instead of being lost.
fein also flags **automated senders** (no-reply robots, notification
services) and hides them from relationship views — on a real inbox they're
otherwise half the graph. Role addresses like `team@` or a client's `hello@` are
treated as hints only and need broadcast behaviour (never replies, never in a
meeting) to be flagged, because a shared mailbox usually has a human behind it.
Nothing is deleted, every flag carries its reason, and `fein automated
--list` shows the lot.
## Relationship radar — the timing layer

Strength answers *who do I know well*. Radar answers *who should I contact now*.
Each pair's natural cadence is learned from real contact history, so **overdue
means overdue for them**: three weeks of silence is unremarkable with a
quarterly contact and alarming with a weekly one.
```bash
fein radar # whole graph, most actionable first
fein radar "Maya Chen" # one person's relationships
```
Every row carries its receipts — "last contact 16d ago · usually every 10d ·
6d overdue · 3 touches" — and statuses (`active`, `due`, `overdue`, `cold`,
`dormant`, `new`) plus a warming/steady/cooling trend comparing the last 90 days
with the 90 before. With no contact in either window the trend is `null` rather
than a fabricated "steady". Entirely deterministic: intervals and dates, no
model in the loop. Radar respects privacy layers, and agents get it as the
`relationship_radar` MCP tool.
## Privacy layers

A relationship graph is only useful if people are willing to put their inbox in
it — and nobody wants to hand their personal email to the whole team. So each
member connects their own sensitive sources into a **private layer** that lives
inside the shared graph:
- **Evidence is private.** Connection strengths, signals, and documents from a
member's layer are visible only to them. Another member's brief on the same
person shows a `withheldDocuments` count and nothing else.
- **Existence is shared.** If the only route to someone runs through a
colleague's private layer, you're told the route exists, which hop is locked,
and *who to ask* — with no strength attached. That's the whole point of a
relationship graph: "Seb can reach Priya, ask him."
- **Layers combine, they don't replace.** Your own evidence is summed with the
shared layer before saturation, so private data reinforces public data.
- **Absorption is layered.** Resolution folds what it learns about a person
into their entity record — but evidence absorbed from a private mailbox (a
second address, an org affiliation, a fuller name) is visible only to that
mailbox's owner, overlaid at read time. The shared record only learns what a
shared source has independently witnessed, and display names upgrade only
from shared mentions. Databases written by v0.4.0 or earlier predate this
policy — run `fein reresolve` once after upgrading (the server warns on
boot until it is done).
```bash
fein members add "Seb Larkin" seb@ridgeline.vc
fein ingest seb-inbox.mbox --as "Seb Larkin" # → Seb's private layer
fein path "Tom Merrill" "Priya Nair" --as "Tom Merrill"
```
All of this works without the terminal too: the Data tab manages members, the
**Uploads land in** selector above the dropzone targets a member's private
layer, and the **Viewing as** switch in the header changes whose view you see.
For agents, `?as=<member>` on the MCP URL (or `FEIN_VIEWER=<member>` for
stdio) binds an MCP server to one person's view. Every `?as=` — API reads,
uploads, and `/mcp` alike — accepts a member id, exact name, or email (names
and emails match case-insensitively; ids are exact), and an unknown or
ambiguous ref is a hard 400 listing the candidates, never a silent answer
from the shared layer.
**What "existence is shared" actually means.** A person the firm already knows
(they appear in any shared document) stays visible to everyone, and a colleague's
private correspondence with them surfaces as a locked hop — that is the feature.
But an entity that appears *only* inside one member's private layer is hidden
entirely by default, because the name itself can be the secret ("Project
Nightjar"). Set `privateEntityVisibility: "reveal"` in Settings to opt into the
fully-shared-names model instead; it is a deliberate choice, not a default.
Removing a member forces an explicit choice: delete their documents, or move
them into the shared layer where everyone will see them. Enforcement is
server-side on every query and `npm test` includes a **leak probe** that stuffs
markers into a private layer and greps every endpoint's response as another
member, but this is a **cooperative** model for a trusted
team on one local database, not a hostile-tenant boundary: anyone with
filesystem access to `./data` or the ability to pass an arbitrary `?as=` can
read any layer. Real multi-tenant isolation needs authentication, which is on
the roadmap below.
## Design principles
1. **Everything resolves to two entities: people and organizations.** Deals, funds, docs hang off those two.
2. **Two-layer data model.** A metadata layer tracks what was ingested and who was mentioned; the knowledge graph holds resolved entities and weighted connections. The graph is a read model — rebuilt deterministically, never hand-edited.
3. **Four-stage entity resolution:** blocking → candidate generation → probabilistic matching → human review. Deterministic auto-merge above a confidence threshold; the band below it queues for a human (defaults 0.95 and 0.70–0.95, tunable in Settings); conflicting evidence (same name, different work domain) always asks. Without this, one person appears as 100+ duplicates across sources.
4. **Never let an LLM score a relationship.** Connection strength is computed from observable signals — meeting frequency, email reciprocity, co-authorship, recency decay — because models will confidently hallucinate a 3/10 relationship as a 10/10.
5. **Graph-based retrieval, not pure vector.** "Who can intro me to X?" is a weighted shortest-path query, answered with the evidence behind each hop.
## MCP — agents on the graph
The dashboard serves the graph as a Streamable-HTTP MCP endpoint at
`http://localhost:4321/mcp` — same process, same live database, no conflict
with the embedded single-process rule. With `npm start` running:
```bash
claude mcp add --transport http fein http://localhost:4321/mcp
```
(Claude Desktop: Settings → Connectors → add the same URL. The Data tab shows
this command with a copy button.) Append `?as=Seb%20Larkin` to bind the agent
to that member's private layer — unknown members are rejected, never silently
given the shared view.
Without the dashboard running, the stdio flavor works anywhere:
```bash
claude mcp add fein -- node /path/to/fein/src/cli.js mcp
```
Tools: `meeting_prep` (one call: profile + relationship history + receipts + your warm paths to them), `company_memory` (every recorded deal signal for a company — investments *and passes with their reasoning* — with document provenance), `relationship_radar`, `find_warm_path`, `find_introducers`, `entity_brief`, `search_entities`, `strongest_connections`, `list_events`, `event_guests`, `event_history`, `guest_league`, `graph_stats`, `review_queue`, `review_resolve`.
## CLI
```
fein web [port] dashboard (default 4321)
fein ingest <file> .jsonl | .mbox | .ics | .csv
fein ingest-granola [path] Granola local cache (macOS)
fein ingest-gog <service> live pull via gog: gmail | calendar | drive
fein ingest-google <service> live pull via Google APIs
fein sync [--extract] resolve + rebuild edges (--extract mines bodies first)
fein extract [--limit N] LLM mention extraction over unprocessed bodies
fein reresolve rebuild entities from scratch (decisions replayed)
fein memory <company> fund memory: deal signals — investments + passes with reasoning
fein events [event] events with per-tier counts, or one event's guests
fein history <person> a person's event history + show rate
fein league <sort> guest league tables (most_attended, never_attended, most_invited, lapsed, best_show_rate)
fein entities | brief | path | intros | review | stats
fein mcp MCP server (stdio)
```
## Unstructured extraction
Headers and attendee lists are a fraction of what a fund knows. The bodies — "our
IC chair Alistair Penhale has asked…", "Sam Okafor at Halcyon co-invested with us
on three deals" — name people and organizations no structured field ever sees.
`fein extract` mines them with an LLM and feeds the results through the
*same* resolution, review, and edge pipeline as everything else:
```bash
export ANTHROPIC_API_KEY=... # or `ant auth login`
fein extract # mine all unprocessed bodies
fein sync --extract # or as part of a sync
```
(Or press **Extract pending documents** on the dashboard's Data tab.)
Extraction never gets to bend the graph's rules:
- **Structured output, not free text** — the model can only return typed
mention candidates; a prompt-injected document can at worst distort which
candidates come back, never make the pipeline do something.
- **Deterministic grounding** — every candidate must literally appear in the
document text. Names not in the text are dropped; emails are kept only if the
exact address string is present (a model can never "complete"
`name@domain` into existence); low-confidence candidates are dropped.
- **Same trust model as any mention** — extracted mentions carry
`origin='extracted'`, a confidence, and a verbatim source quote; they resolve
through blocking → matching → human review like structured mentions, and
co-occurrence is damped by the merely-`mentioned` factor. Connection strength
stays deterministic (principle 4): the LLM proposes candidates; it never
scores a relationship.
- **Idempotent + resumable** — each document records a hash of
(prompt version, model, body); re-runs skip clean documents, re-extract
changed ones, and retry failures. Three consecutive failures abort the run.
Extraction also mines **fund memory**: when a document records an investment
decision (an IC memo's INVEST or PASS, a board pack, a round discussion), a
`deal` record is kept — company, stage, status, the stated reasoning, and the
document it came from. `fein memory <company>` or the `company_memory`
MCP tool answers the question every fund eventually asks: *"have we seen this
company before, and why did we say no?"* Deals hang off organizations
(principle 1) and link to entities at query time, so rebuilds never orphan
them; passes are first-class, because a recorded no is the memory that saves
the next diligence cycle.
Configuration: `FEIN_EXTRACT_MODEL` (default `claude-opus-5`;
`claude-haiku-4-5` is the budget option), `FEIN_EXTRACT_EFFORT`
(default `low`), `FEIN_EXTRACT_MIN_CONFIDENCE` (default `0.6`).
Details, cost notes, and the threat model: [docs/extraction.md](docs/extraction.md).
## How connection strength works
Each co-occurrence contributes `weight(kind) × decay(age)`: meetings 3, calendar events 2, direct emails 2.5 (cc'd 1), co-authored docs 1.5, merely-`mentioned` participants halved — 180-day half-life. Strength is `1 − e^(−W/6)`, saturating toward 1. Warm paths maximize the product of hop strengths (hop-bounded Dijkstra over `−ln(strength)`). Every number is tunable in Settings, per database.
## Testing
```bash
npm test # 13 suites: resolution, ingest, API, extraction, privacy, radar, automated, merge, leak probe, auth, connectors, edges, scheduler
```
The extraction suite runs the full pipeline against a scripted fake model —
grounding, idempotency, failure isolation, and resolution integration are all
covered offline; no API key needed.
All suites run on throwaway databases. Recent releases have each gone through adversarial multi-agent review; confirmed findings are fixed, the most serious pinned by regression tests (see the [CHANGELOG](CHANGELOG.md)).
## Status & roadmap
Working today: everything above, including token-gated client deployments
(see [DEPLOY.md](DEPLOY.md)). Per-member privacy is *view* scoping behind one
shared deployment token — real multi-tenant isolation (per-user login) is on
the roadmap. What's next, in priority order: [ROADMAP.md](ROADMAP.md).
PRs welcome.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues