System Architecture · As Built

WiFiScanner AI SDR — full architecture

A lightweight, fully serverless cold-email pipeline: Bedrock derives who to target from a product's website, Clay finds and enriches matching people, Bedrock personalizes and sends, and every AI-authored message to a real person — the first cold email's follow-ups aside — waits on a human click before it goes out.

Lambda + DynamoDB + API Gateway + EventBridge No Fargate · No EC2 · No database server Deployed to development 2026-07-18

Scaling the sending side past one shared inbox? See architecture-2.0.html — a target-state roadmap doc, not yet built.

What this is

Ten Lambda functions, four DynamoDB tables, and one HTTP API — nothing runs when nothing is happening, and nothing needs patching between deploys. It replaced an earlier, much larger design (Next.js dashboard on ECS Fargate, Postgres, multi-user roles) that was scaffolded and then deliberately set aside in favor of this smaller build; that fuller platform still exists in git history if the project outgrows this version.

Two design choices carry through the whole system and are worth stating up front: every table is pay-per-request (no capacity to size, no idle cost), and every AI-drafted message to a prospect requires a human click before it sends — the ICP a campaign targets, and every reply a lead gets, both wait in an email to the sales inbox with an approve link. Nothing about that is automatic.

Pipeline

Target
POST /campaigns
{ targetUrl }
icp-discovery
fetch site + Bedrock
campaigns table
status: pending_review
⏸ human approves
icp-review-response
status: approved
Source
Clay
finds + enriches people
clay-webhook
dedupe + suppression check
leads table
status: new
Send
personalize
every 10 min · Bedrock
leads table
status: personalized
send-scheduler
inbox rotation · SMTP
leads table
status: sent
Reply
EventBridge
every 5 min
reply-poller
IMAP → unsubscribe check → Bedrock
leads table
status: pending_reply_review
⏸ human approves
reply-review-response
status: replied
Close
Google Calendar
SE's booking page
calendar-webhook
match attendee → lead
leads table
status: meeting_booked
daily-digest
once/day → sales inbox

Lambda functions

FunctionTriggerDoes
icp-discoveryAPI — POST /campaignsFetches the target homepage, Bedrock derives an ICP, emails sales inbox an approve link
icp-review-responseAPI — GET /campaigns/{id}/approveMarks a campaign approved; Clay sourcing can start
clay-webhookAPI — POST /campaigns/{id}/leadsValidates Clay's shared secret, checks suppression, idempotent lead insert
personalizeEventBridge — every 10 minBedrock drafts subject + opener per new lead, grounded in the campaign's ICP
send-schedulerEventBridge — every N min (env-configured)Picks least-used active inbox, sends via SMTP, writes booking + unsubscribe links into every email
reply-pollerEventBridge — every 5 minIMAP-polls every active inbox, deterministic unsubscribe-phrase check, then Bedrock classifies + drafts a reply, emails sales inbox for approval
reply-review-responseAPI — GET /leads/{id}/{email}/approve-replySends the approved draft in-thread via SMTP
calendar-webhookAPI — POST /calendar/webhook (Calendar push)Syncs the SE's calendar, matches new-event attendees to leads
unsubscribeAPI — GET /unsubscribe/{id}/{email}Writes suppression directly — independent of any AI classification
daily-digestEventBridge — once/dayEmails the sales inbox a summary of the last 24h (new leads, top companies, meetings booked)

Data model

Four DynamoDB tables, all pay-per-request. leads uses a single-table pattern for two secondary lookups instead of extra GSIs — cheaper and simpler at this scale.

campaigns

PK: campaignId

targetUrl, status (pending_review / approved), icpProfile, bookingLink, salesEmail, approvalToken

leads

PK: pk · SK: sk · GSI: gsi1-status

CAMPAIGN#id / LEAD#email for lead records; THREAD#id and EMAIL#email pk/sk pairs in the same table as lookup shortcuts for the reply and calendar webhooks. gsi1 (status → campaign#createdAt) lets scheduled Lambdas query "all leads in status X" without a table scan.

suppression

PK: emailNormalized

Global, not per-campaign — one unsubscribe stops every campaign from ever emailing that address again. Checked on every ingest and every send.

inbox-pool

PK: inboxEmail

dailyCap, sentToday, lastResetDate, status, lastImapUid (IMAP's incremental-sync cursor for the reply poller)

External integrations

SystemUsed forHow
Bedrock (Converse API)ICP discovery, personalization, reply classification/draftingAWS SDK, IAM-scoped, no external credentials
Gmail (SMTP/IMAP)Cold sends, reply detection (5-min poll), in-thread repliesnodemailer (send) + imapflow/mailparser (poll), a Google App Password per inbox in Secrets Manager — no OAuth, no Google Cloud project
Google Calendar APIMeeting capture via push notifications + incremental syncService account (RFC 7523 JWT bearer, raw fetch) — no OAuth consent screen, no refresh token; the target calendar just needs to be shared with the service account's email
ClayLead sourcing + enrichmentOutbound webhook from Clay — accepts either a static shared-secret header or Clay's own whsec_ webhook signature (Svix/Standard-Webhooks-shaped; unconfirmed against a real Clay request), no AWS credentials given to Clay
SESICP review requests, reply-approval requests, daily digest, meeting alertsTransactional only — never cold outbound, so it carries no sender-reputation risk

Why two human-review gates, not one

The ICP gate stops a bad Bedrock guess from burning Clay credits on the wrong audience. The reply gate stops AI-authored content from reaching a real person unreviewed — Bedrock classifies and drafts, but nothing sends until someone clicks approve. Both use the same pattern: a one-click link email, no inline editing (reject by handling it manually or resubmitting instead). This was a deliberate reversal from an earlier version of this build where replies auto-sent — worth knowing if the tradeoff ever needs revisiting for volume.

Test harness

docs/admin.html, hosted on the same S3 site as this page, is a self-service form for proving the pipeline against a new target URL before Clay is wired up for it — no chat/CLI typing, no direct DynamoDB access. It creates a test campaign (URL + optional approver/booking-link override), submits a test lead by calling the same createLead() dedupe/suppression logic the Clay webhook uses (bypassing Clay itself), triggers Personalize/Send/Reply-poll synchronously instead of waiting on the EventBridge schedule, and checks a lead's status. The sending/reading inbox is never a form field — it's always the shared pool, same as production. Gated by a shared X-Admin-Token header (Secrets Manager, auto-generated), kept in the browser's localStorage rather than in the page itself since the docs bucket is public. It does not skip either human-review gate above — the ICP approval email still has to be clicked before a test lead can be submitted.

Security notes

Deliberately not built

Status

Typechecks clean cdk synth passes Deployed to development (account 321417400622, us-east-1) First target: wifiscanner.com

Live so far: sending domain (trywifiscanner.com, Route 53), SES fully verified, one Gmail inbox with an App Password added to the pool, Bedrock model access confirmed (us.anthropic.claude-haiku-4-5-20251001-v1:0), Clay webhook auth confirmed reachable end-to-end. The full pipeline has been exercised live, real cold send through real Bedrock-drafted reply through real approval — one real meeting booking even showed up along the way. The wifiscanner.com campaign is created and sitting at pending_review — approving it starts real Clay ingestion and real sends, so that's held for an explicit go-ahead. Still open: the Google Calendar service account (create it, share the calendar with it, register the watch). All documented in the repo README.