Skip to main content
Glama
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.

![fein dashboard](docs/img/dashboard.png)

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](docs/img/dashboard.png)

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

| | |
|---|---|
| ![warm path](docs/img/warm-path.png) | ![review queue](docs/img/reviews.png) |

- **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

![Attio connector](docs/img/attio-connected.png)

**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

![events](docs/img/attio-connected.png)

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

![radar](docs/img/radar.png)

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

![privacy layers](docs/img/privacy-tom.png)

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

Maintenance

ActivityMaintained
ResponsivenessNo issues