Fundable Personalization API
Identity in, one ICP and one claim-gated email out, on every GTM surface.
TypeScriptNext.js 16Node 22npm workspacesNeon PostgresOpenRouter (DeepSeek)Exan8nVercelGitHub Actionsnode:test
Problem
Fundable needed the same ICP decision on every GTM surface (signup, website visitors, follow-ups, cold outbound) plus a personalized email body that would actually be sent to a named person. The existing classification had no versioned definition and no frozen baseline, so the same lead could get different labels on different surfaces, and a single model call was a coin flip on borderline leads. A wrong label or an unsupported claim lands in front of a real human, so guessing is the expensive failure.
What I built
A TypeScript API (Next.js 16, npm workspace with a shared package) exposing POST /api/v1/personalize: identity resolution through an n8n vendor cascade or a people lookup, company research via Exa, a registry-driven three-vote classifier over DeepSeek via OpenRouter, use-case selection from a workflow catalog, template composition, a claims gate and output validation, fail-closed at every step. Success is exactly six keys, or a documented prefix of them via a stop_at mode, so one reader handles every caller. All behaviour lives in versioned JSON (ICP registry, use-case catalog, template catalog, approved claims): the classifier prompt is built from the registry at load, registries validate at load so a bad edit fails the build rather than a request, and labels are cached in Neon Postgres keyed on every version that can change the answer. Bearer auth with constant-time multi-key comparison, a durable-plus-in-memory rate limit, idempotency keys and per-stage timing headers round out the contract, and a resumable CSV backfill script plus a debug-classify tool answer why this lead got that label.
Highlights
- Caller fields can never satisfy an evidence gate: the prompt separates caller assertions from gathered research, and after prompt instructions alone failed, a deterministic filter drops description-shaped company names before the model sees them. Registry exclusions run against research evidence only, so a caller cannot force a rejection either.
- Determinism as an acceptance criterion: 7 of 10 borderline leads flipped labels across identical runs. Fixed by turning off the provider hedge, running three concurrent votes resolved on the first two that agree, and caching the verdict keyed on registry, prompt, research-question and vote-count versions. Agreement is a response header so a 2/3 vote can route to human review.
- Failures are never verdicts: a dead model or research dependency is a 502, malformed model output gets one retry then falls back, a slow people lookup degrades instead of dropping the lead, and an email-domain versus employer conflict is a 409 stop. Not Core is a label with an approved generic body, never an error.
- Copy is claim-gated in code: catalog templates may only assert claims marked approved in a versioned registry. A violation in catalog copy is a 502 (our defect); a relationship claim in a caller's own template is a 422 (theirs), while their own numbers and names pass through by design. The validator rejects unresolved tokens, HTML and empty greetings, and the composer conjugates a/an itself after a real run produced 'a investor team'.
- Measured instead of assumed: showed the accept path is input-bound (12% core recall without a job title, 61% with title and company), built and then rejected in-house title resolution after it returned the wrong person for 2 of 8 leads, and found one leading research question was rejecting an ICP that had no such gate (recall 1/15 to 8/15 after rewording).
Numbers
- 118 offline tests in the verify loop (typecheck + tests + build), run in CI on every push
- Borderline-lead label instability 7/10 before disabling the hedge and adding evidence-keyed caching, 0/10 after
- p95 latency 39.0s to 12.7s, and 6.4s with a warm cache, after caching identity lookups and running research alongside identity
- 27/27 agreement with the hand labels on the 27 classified rows of a 29-row website-visitor set (2 rows had no email and were refused)
- Core-ICP recall 12% without a job title vs 61% with title and company, measured against a labelled export (an upper bound, not a forecast)
- One ICP's recall 1/15 to 8/15 from rewording one research question, false positives unchanged at 0/40
- In-house title resolution returned titles for 8/8 leads but the wrong person for 2, so it was rejected
- 26 live contract cases, 21 hard-rule fixtures, 19 ICPs in the registry, 6 alert families and 11 MCP workflows in the use-case catalog
Screenshots
