A small single-user CRM that finds small companies on Apollo.io, saves them and their founders to Postgres, and lays the groundwork for Gmail outreach and a daily “who wrote to me?” digest. Seven steps, each ending in a checkpoint — don’t move on until it passes.
Download the skill
This page is the build-connect-ai skill, written out. Download the file, put it in your project, and Claude Code walks the steps with you. It stops at every checkpoint and asks whenever it needs something only you have.
Used lightly — most styling comes from a small set of hand-written house classes (Step 3)
Prisma ORM
v7+, with the pg driver adapter, on any Postgres
Apollo.io
REST API for company and people data
sonner · lucide-react
Toasts and icons
Seven steps
00Step 0
Gather what you need
Ask for these up front. When a step needs something only you have — a key, a database URL, a choice of host — Claude stops and asks. It never invents credentials.
Which Postgres host? Any works. Prisma Postgres is easiest locally (npx prisma dev prints a URL). Supabase: Project → Connect → ORMs → Prisma — the pooled URL (port 6543, ?pgbouncer=true) for the app, the direct one (5432) for migrations. Neon: pooled for the app, unpooled for migrations. Local Docker Postgres also works.
An Apollo.io API key (Settings → API) on a paid plan — on Free the search and reveal endpoints reply “not included in your Free plan”. Company and people search are free; enriching a company costs 1 credit, revealing a person’s email costs 1 credit.
In this class: Apollo credits come from one shared class key that sits behind /api/apollo/* — you mint a personal token there instead of holding a key. The proxy forwards company search, people search and people match (plus bulk match), but notorganizations/bulk_enrich, so Step 4’s company enrichment needs your own paid key or has to be skipped.
CheckpointYou have a database connection string (or a plan for getting one) and an Apollo key.
Delete the starter src/app/page.tsx and the stock SVGs in public/.
Run npx shadcn@latest init, then npx shadcn@latest add checkbox sonner badge button input label table.
Install the rest: npm i @prisma/client @prisma/adapter-pg pg and npm i -D prisma dotenv @types/pg.
Put the logo at public/connectai-logo.png — any square PNG works as a stand-in.
Next.js version warning. Recent Next.js releases change APIs older tutorials rely on — route params is a Promise and must be awaited, and PageProps/LayoutProps are generated global types. Read the guides in node_modules/next/dist/docs/ before writing routes.
Checkpointnpm run dev starts, and the Next docs exist in node_modules/next/dist/docs/.
02Step 2
Database and schema
Run npx prisma init, then configure Prisma 7 the modern way: the prisma-client generator with output ../src/generated/prisma and no url in the datasource; a prisma.config.ts with import "dotenv/config" and defineConfig({ schema, migrations, datasource }). On pooled hosts point its url at process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL — migrations can’t run through a pooler. Gitignore /src/generated/prisma.
Use the data model below. Status fields are plain strings, so the app can add states without a migration.
Create .env.example (commented) and copy it to .env: DATABASE_URL, DIRECT_DATABASE_URL, APOLLO_API_KEY, TOKEN_ENCRYPTION_KEY (32 random bytes, hex), CRON_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, NEXT_PUBLIC_APP_URL. Only database, Apollo and encryption are needed now. Never commit .env.
Run npx prisma migrate dev --name init, then npx prisma generate.
Write src/lib/db.ts: throw a clear error if DATABASE_URL is missing, build new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) from @/generated/prisma/client, and cache it on globalThis outside production.
Add GET /api/health: runs db.$queryRaw`SELECT 1`, returns { ok: true, db: "connected" } or a 500.
prisma/schema.prisma — the six models
model Company {
id String @id @default(cuid())
apolloOrgId String? @unique
name String
domain String? @unique
industry String?
employeeCount Int?
city String?
state String?
country String?
linkedinUrl String?
websiteUrl String?
status String @default("new") // new | researching | contacted | responded | not_interested
notes String?
contacts Contact[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Contact {
id String @id @default(cuid())
companyId String?
company Company? @relation(fields: [companyId], references: [id])
apolloPersonId String? @unique
firstName String?
lastName String?
email String @unique
title String?
linkedinUrl String?
phone String?
source String @default("apollo") // apollo | manual | inbound
status String @default("new") // new | emailed | replied | not_interested
outreachEmails OutreachEmail[]
inboundEvents InboundEmailEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
}
model OutreachEmail {
id String @id @default(cuid())
contactId String
contact Contact @relation(fields: [contactId], references: [id])
gmailMessageId String? @unique
gmailThreadId String?
subject String
bodyText String
status String @default("sent") // sent | failed
errorMessage String?
sentAt DateTime @default(now())
@@index([contactId])
}
model InboundEmailEvent {
id String @id @default(cuid())
gmailMessageId String @unique
fromEmail String
fromName String?
subject String?
receivedAt DateTime
matchedContactId String?
matchedContact Contact? @relation(fields: [matchedContactId], references: [id])
includedInDigestAt DateTime?
createdAt DateTime @default(now())
@@index([matchedContactId])
}
model GmailIntegration { // single row, id = "singleton"
id String @id @default("singleton")
gmailEmailAddress String?
refreshTokenEncrypted String?
accessTokenCache String?
accessTokenExpiresAt DateTime?
scopes String?
lastHistoryId String?
connectedAt DateTime?
}
model DigestRunLog {
id String @id @default(cuid())
runAt DateTime @default(now())
newSendersFound Int @default(0)
digestEmailSent Boolean @default(false)
historyIdBefore String?
historyIdAfter String?
errorMessage String?
}
Checkpointcurl localhost:3000/api/health returns {"ok":true,"db":"connected"}, and npx prisma studio shows all six tables.
03Step 3
Look and feel
Editorial and academic, not dashboard-y: white paper, deep navy text, one crimson accent, serif headlines — the look of this page. Define the tokens below in globals.css and map shadcn’s onto them in @theme inline (background → paper, foreground → navy, primary/ring → red, border/input → line).
Chrome — a fixed 3px crimson bar across the top; the logo as a ~5% watermark bottom-right; a 1280px content wrapper; a 54px logo + serif “Connect.AI” lockup.
Nav — mono, 11px, uppercase, wide tracking, hairline rules above and below; the active item crimson.
Page head — PageHead({ eyebrow, title, accent, lede }): a mono eyebrow with a crimson tick, a serif title at clamp(38px, 5vw, 60px) whose last phrase is an italic crimson<em>, a short lede.
Building blocks — stat tiles with huge serif numbers; “rows” lists with a mono crimson key column; tables with a 2px navy header rule and serif company names; pill badges (ready / navy / starter); crimson pill buttons in mono caps plus a ghost variant; square-cornered inputs; toggle chips (selected = navy).
Motion — a subtle fade-up on load, off under prefers-reduced-motion. No dark mode.
Put the chrome, a SiteNav client component (usePathname for the active state), <main> and the footer “Connect.AI · Student-led forward-deployed engineering” in a (dashboard) route-group layout. The root layout sets the title and renders sonner’s <Toaster />. /outreach and /inbox-alerts get a ComingSoon: a PageHead plus a “Not built yet” badge.
CheckpointEvery nav link renders a styled page, and the look matches the description above.
04Step 4
The Apollo client
Everything lives in src/lib/apollo/. Base URL https://api.apollo.io/api/v1; headers x-api-key, Content-Type and Accept as JSON. One apolloRequest<T>(path, { method, body?, query? }) helper that throws an ApolloError(message, status) on a non-OK response, taking the message from the body’s error_message or error.
Function
Endpoint
Cost
Notes
searchCompanies
POST /mixed_companies/search
free
Send organization_locations, organization_num_employees_ranges (e.g. "1,10"), q_organization_keyword_tags, page, per_page (max 100). Results carry identity only — id, name, primary_domain, website, LinkedIn, founded year. No industry, size or location.
searchPeople
POST /mixed_people/api_search
free
Send organization_ids or q_organization_domains_list, plus person_titles. First name, obfuscated last name, title and has_email. Never an email.
enrichOrganizationsByDomain
POST /organizations/bulk_enrich
1 credit / match
Max 10 domains per call, so chunk. Returns a Map keyed by the requested domain; match on primary_domain, falling back to website_url containing it. Gives industry, estimated_num_employees, city, state, country.
revealPerson
POST /people/match
1 credit
Returns the full name, email, email_status, title and LinkedIn.
Keep the free functions and the credit-spending ones in separate files, with a comment at the top of each saying which is which.
CheckpointCall searchCompanies({ locations: ["Connecticut, US"], employeeRanges: ["1,10"] }) from a scratch script or route and see real companies come back.
05Step 5
API routes
All POST with JSON bodies. On failure they return { error } with a sensible status: 400 bad input, 404 not found, 502 when Apollo errors.
/api/apollo/search-companies
Requires at least one filter; returns the search page.
/api/apollo/search-people
{ companyId, titles } — searches by apolloOrgId if the company has one, otherwise by domain; 400 if it has neither.
/api/companies/import
{ companies } — enriches every domain in ONE bulk pass, then upserts each company (key: domain, else apolloOrgId). Enriched values win. Returns { imported, skipped: string[] }.
/api/contacts
Adds a contact by hand. Email required, trimmed and lowercased; source: "manual". A duplicate returns 400 “A contact with this email already exists.”
/api/contacts/reveal
{ companyId, apolloPersonId } — calls revealPerson. 404 if no match, 422 if no email; otherwise upserts by lowercased email with source: "apollo" and returns { contact, emailStatus }.
Checkpointcurl each route with a realistic body. The search routes should work without spending credits.
06Step 6
Pages
Any server page that reads the database exports dynamic = "force-dynamic".
Overview — one Promise.all for the four counts (emails sent = OutreachEmail with status: "sent"; new senders = InboundEmailEvent with no matched contact) plus the 6 newest companies with _count.contacts. Title: “Every company you could reach. One place.”
Find companies (client) — a location input (default “Connecticut, US”), comma-separated keywords, employee chips 1–10 · 11–20 · 21–50 · 51–100 · 101–200 · 201–500 · 501–1000 · 1001–5000 · 5001–10000 · 10001+ (first three on). 25 per page, checkboxes, Select all, Previous/Next. “Add N to companies” imports, toasts, and goes to /companies.
Companies — newest first; the empty state links to Find companies.
Company detail — domain, employees, LinkedIn, notes, contacts; a FindPeople panel (title chips: Founder, Owner, CEO on; President, Partner, Director off) whose “Reveal & save” is disabled when hasEmail is false and refreshes on success; an AddContactForm (first, last, email, title).
Contact detail — back to the company, then email, LinkedIn, a status badge and outreach history; the empty history says Gmail sending arrives next phase.
Settings — a server page showing Configured / Missing for each integration.
CheckpointEnd to end: search Connecticut companies, import 2 (about 2 credits — ask first), open one, find its founder, reveal and save them (1 credit), and see the contact on the company page and in the Overview counts.
07Step 7
Finish
npm run build passes. It needs DATABASE_URL to be set, but doesn’t connect.
npx tsc --noEmit passes — run it after the build, which generates the route types.
npm run lint passes.
git init and commit. Confirm .env and src/generated/ are not in it.
CheckpointBuild, types and lint all green, and a clean first commit.
Later phases
Only when you ask for them.
Phase 3
Gmail outreach
Google OAuth — Gmail API, consent screen in Testing mode, redirect /api/gmail/oauth/callback.
The refresh token lives in GmailIntegration, encrypted AES-256-GCM with a key derived from TOKEN_ENCRYPTION_KEY via scrypt, stored as iv.tag.ciphertext in base64.
Send from the contact page, log each email to OutreachEmail, and set the contact to emailed.
Phase 4
Daily inbox digest
A scheduled Claude Code routine calls POST /api/routines/daily-inbox-check with an x-cron-secret header.
The route reads Gmail history since lastHistoryId, records senders who aren’t contacts as InboundEmailEvent, emails a digest linking back to NEXT_PUBLIC_APP_URL, and writes a DigestRunLog.