Fernando Millan
HomeAboutProjectsResumeContact⌘K

Full-Stack Engineer building production-ready SaaS applications

> navigation

  • Home
  • About
  • Projects
  • Contact

> connect

  • > github

> EOF

© 2026 Fernando Millan. All rights reserved.

Back to Projects

AI SDR

AI-powered lead qualification, enrichment, and personalized email generation with real-time SSE streaming

View Live DemoView Source
> OVERVIEW

Overview

AI SDR is a production-ready AI Sales Development Representative system built to demonstrate full-stack AI engineering skills. It uses the Claude API to qualify leads against an ICP rubric, enrich company profiles from public data, and generate personalized cold emails — all streamed in real time via Server-Sent Events. Eight pre-seeded leads with hand-authored AI outputs let recruiters explore the full feature set immediately with no setup.

3
Pipeline Steps
ICP
Scoring
Live
Streaming

Features delivered:

  • Lead intake form creating records in PostgreSQL via NestJS REST API
  • Three-step AI pipeline: qualify (ICP scoring) → enrich (company research) → personalize (email generation)
  • Real-time pipeline progress via Server-Sent Events (SSE) streamed from NestJS to Next.js
  • ICP ScoreCard with color-coded horizontal bar (green / amber / red) and numeric score
  • Why This Score accordion showing matched ICP criteria and weak criteria bullets
  • Enrichment card surfacing company tech stack, funding stage, headcount, and pain points
  • Streaming email preview — email tokens arrive in real time as Claude generates them
  • One-click copy to clipboard for the generated cold email
  • Pre-seeded demo with 8 hand-authored leads across FinTech, Healthcare, DevTools, and Logistics
  • Global rate limiting (ThrottlerGuard) with per-endpoint overrides
  • Docker + Coolify deployment with X-Accel-Buffering: no for SSE through Nginx

Tech stack:

Next.js 16NestJS 11TypeScriptClaude APIAnthropic SDKPrismaPostgreSQLDockerTailwindSSE
> PROBLEM

Problem

Sales Development Representatives spend the majority of their time on tasks that follow predictable patterns but require significant cognitive effort:

  • Manual lead qualification: Reps read every incoming lead against ICP criteria manually — a repeatable rubric that AI can apply consistently at scale.
  • Company research overhead: Before writing an email, reps spend 15-30 minutes researching the company's funding stage, tech stack, and pain points across multiple sources.
  • Generic outreach: Volume pressure leads to template-based emails that buyers ignore — personalization at scale is the core problem cold outreach has never solved well.
  • Slow feedback loops: Reps submit leads and wait for batch processing results — no visibility into which step is running or how long it will take.
> SOLUTION

Solution

AI SDR replaces the three most time-consuming SDR tasks with a sequential AI pipeline that runs in real time and streams progress back to the user:

  • Qualify step: Claude scores the lead against a structured ICP rubric using Zod structured outputs at temperature 0 — deterministic, auditable scores every time.
  • Enrich step: The pipeline scrapes the company website, extracts facts (tech stack, funding, headcount, pain points), and stores them as structured JSON for the next step.
  • Personalize step: Claude writes a personalized cold email using enrichment context as input — company-specific facts, not templates. Email tokens stream to the UI in real time via SSE.
  • Live progress indicators: Each step shows a spinner while running and a checkmark when complete — reps see exactly which step is in progress without polling.
  • Pre-seeded demo data: Eight hand-authored leads with complete AI outputs load on first login so recruiters experience the full feature set instantly without waiting for live pipeline runs.
> ARCHITECTURE

Architecture

AI SDR uses a standalone two-app architecture — a Next.js 16 frontend and a NestJS 11 backend — deployed as separate Docker containers on Coolify. This proves the ability to architect either a standalone repo or a Turborepo monorepo depending on project requirements.

Browser (Next.js Client Components)
↓ EventSource (SSE)
Next.js App Router (RSC + Server Actions)
↓ HTTP REST + SSE
NestJS Backend (LeadsController → PipelineService)
↓ Anthropic SDK
Claude claude-sonnet-4-6 (qualify → enrich → personalize)
↓ Prisma ORM
PostgreSQL (Lead, AIOutput, DemoLead tables)
Standalone Repo Structure:
  • ai-sdr/web/ — Next.js 16 frontend with App Router, Server Components, and Client Components for SSE
  • ai-sdr/api/ — NestJS 11 backend with modular architecture (LeadsModule, PipelineModule, ClaudeModule)
  • ai-sdr/api/prisma/ — Schema, migrations, and seed script — no shared packages required
Frontend Layer:
  • Next.js 16 App Router for SSR and routing
  • Server Components for initial data fetch (leads list page)
  • Client Components for SSE EventSource and clipboard API
  • Server Actions for lead creation with revalidatePath
  • Tailwind CSS with Radix Color tokens and Shadcn components
Backend Layer:
  • NestJS 11 with modular architecture (Auth, Leads, Pipeline, Claude)
  • LeadsController: POST /leads (create), GET /leads (list), GET /leads/:id/pipeline (SSE)
  • PipelineService: orchestrates qualify → enrich → personalize with StepCallback bridge
  • ClaudeService: single abstraction for all Anthropic SDK calls (structured + streaming)
  • Global ThrottlerGuard with per-route @Throttle decorators
Data Layer:
  • PostgreSQL on port 5436 (avoids conflict with teamflow:5434 and devcollab:5435)
  • Prisma ORM with typed queries — Lead, AIOutput (step results), DemoLead (seed idempotency)
  • DemoLead.seedKey @unique enables safe re-runs of the seed script without duplicates
  • AIOutput stores step name + JSON result per lead — qualify and enrich use structured JSON, personalize stores email text
> KEY_TECHNICAL_DECISIONS

Key Technical Decisions

DecisionRationale
SSE over WebSockets
vs. bidirectional channel
Unidirectional pipeline events (qualify→enrich→personalize) need no bidirectional channel. SSE is simpler, works through Nginx with X-Accel-Buffering: no, and has no reconnect complexity. Native browser EventSource handles reconnection automatically if needed.
Structured outputs (Zod + zodOutputFormat)
vs. prompt-engineered JSON
Qualify and enrich steps return typed JSON via Anthropic's native structured output. Temperature 0 ensures deterministic ICP scores across repeated calls for the same input — the ICP rubric is auditable and consistent.
Standalone repo (not Turborepo monorepo)
vs. shared workspace packages
AI SDR is fully self-contained to prove ability to architect either way. No shared packages, simpler Docker build context (./ai-sdr), and no turbo prune needed. Simpler CI/CD without workspace dependency graphs.
Pre-seeded demo data
vs. live scraping on demand
Live scraping fails 20-30% of sites and takes 45-90 seconds per lead. The seed script writes hand-authored AI outputs directly to Postgres so recruiters see a populated app immediately with zero latency and 100% reliability.
In-process pipeline (no Redis/BullMQ)
vs. distributed job queue
Sequential steps (qualify → enrich → personalize) run synchronously in PipelineService. A callback bridge pattern connects StepCallback to an SSE Observable without any queue infrastructure — correct for single-user demo scale, simpler to reason about.
NEXT_PUBLIC_API_URL as Docker build ARG
vs. runtime ENV
Next.js bakes NEXT_PUBLIC_ variables into the client bundle during next build. Docker ARG passes the API URL at image build time — runtime ENV has no effect on client bundle, which would silently break the EventSource URL in Client Components.
> CHALLENGES_&_SOLUTIONS

Challenges & Solutions

Challenge 1: SSE Streaming Through Nginx Reverse Proxy

Nginx buffers SSE responses by default, delivering all tokens in a single batch at the end instead of incrementally. This breaks the real-time streaming experience — locally SSE appears to work (no proxy), but production fails silently.

Solution: Set X-Accel-Buffering: no as a per-request response header in NestJS (leads.controller.ts). Nginx 1.1.4+ honors this header per-connection without requiring server config changes — zero-config Coolify deployment.

Learned: Nginx buffering is silent — SSE appears to work locally but fails in production. The X-Accel-Buffering header is the correct solution; Nginx configuration changes would require Coolify infrastructure access that defeats the point of a managed platform.

Challenge 2: Observable/Callback Bridge for NestJS SSE

NestJS @Sse() expects an Observable<MessageEvent>, but the pipeline runs via async callbacks (StepCallback). Bridging the two without leaking Claude API connections on client disconnect was non-obvious.

Solution: Wrapped the pipeline in new Observable<MessageEvent>(subscriber => ...) and used a closed boolean flag plus subscriber.closed guard in the onStep callback to prevent "Cannot call next on closed subscriber" errors. Added res.on('close', () => closed = true) for explicit disconnect detection.

Learned: The Observable/callback bridge pattern is the cleanest way to connect imperative async code to reactive streams in NestJS. The closed flag is essential — without it, a client disconnect during the email streaming step causes an unhandled error.

Challenge 3: Zod v4 + Anthropic Structured Outputs

The @anthropic-ai/sdk's zodOutputFormat() calls z.toJSONSchema() which only exists in Zod v4+. Zod v3 (default install) causes a silent failure where the structured output schema is undefined, and the API call silently returns unstructured text.

Solution: Upgraded to zod@4.3.6. Also confirmed that nullable() (not optional()) must be used for optional EnrichOutput fields — Anthropic structured outputs require all fields in the JSON schema's required array; optional() would cause the API to reject the schema.

Learned: Structured output schema compatibility is fragile when mixing Zod versions. Pinning to zod@^4.x in package.json prevents silent schema failures. The nullable() vs optional() distinction is a non-obvious Anthropic API constraint.

App Walkthrough

AI SDR CRM dashboard showing 8 pre-seeded leads in a table with color-coded ICP score bars ranging from red (low fit) to green (high fit)

ICP Score BarsColor-coded bars (green 70+, amber 40-69, red below 40) show qualification fit at a glance — no column sorting needed to identify top prospects.

Pre-Seeded Demo DataEight leads spanning FinTech, Healthcare, DevTools, and Logistics are loaded on first login — no setup required for recruiters to explore the full feature set.

Status ColumnThe status badge (pending / processing / complete / failed) reflects the pipeline state so reps know which leads have AI outputs ready to review.

AI SDR lead detail page showing real-time pipeline progress with step indicators for qualify, enrich, and personalize steps, one step completed and one in progress

Step Progress IndicatorsEach pipeline step (qualify, enrich, personalize) shows a spinner while Claude API runs, then a checkmark when complete — giving the user live feedback without polling.

SSE StreamingProgress arrives via Server-Sent Events — the NestJS backend streams step completion events as the pipeline runs, not in a single batch at the end.

Non-Blocking SubmissionThe lead is created immediately with status pending, then the pipeline triggers on first SSE connection — the form submission returns instantly with no artificial wait.

AI SDR lead detail page showing the ICP ScoreCard with a colored horizontal score bar at 85 and the Why This Score accordion expanded with matched and weak criteria bullets

ICP Score BarThe horizontal bar renders in green (70+), amber (40-69), or red (below 40) and shows the numeric score — communicating qualification strength without any jargon.

Why This Score AccordionThe collapsible card lists matched ICP criteria (green checkmarks) and weak criteria (amber warnings) — so reps understand why Claude scored the lead, not just what the score is.

Structured OutputScore and reasoning come from Claude structured outputs via Zod schemas at temperature 0 — the same input always produces the same score, making the ICP rubric auditable and deterministic.

AI SDR lead detail page showing the EmailPreview card with a fully generated personalized cold email and a Copy Email button

Streaming Email PreviewThe email arrives token-by-token from Claude via SSE — the user watches it being written in real time rather than waiting for a completed response.

Company-Specific CopyThe email references company-specific facts from the enrichment step (funding stage, tech stack, pain points) — not generic templates that buyers ignore.

One-Click CopyThe Copy Email button uses navigator.clipboard.writeText() to transfer the full email to clipboard instantly — ready to paste into any outreach tool.

> RESULTS

Results

AI SDR successfully demonstrates production-ready AI engineering capabilities across backend pipeline design, Claude API integration, and real-time streaming UX:

3
Pipeline Steps (qualify → enrich → personalize)
Real-Time
Response Time (SSE streaming)
Production
Deployment

Try the Demo

Demo credentials are shown on the login page. Log in to see 8 pre-seeded leads with ICP scores across industries. Submit a new lead to watch Claude qualify, enrich, and write a personalized email in real time.

Launch Demo