Connect.AI logoConnect.AI
Dashboard
Build Connect.AI · a Claude Code skill

From an empty folder to an outreach CRM.

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.

Download SKILL.mdOne Markdown file · the whole build
  1. Download the file with the button above.
  2. In your project folder, make a folder called .claude/skills/build-connect-ai/ and move SKILL.md into it.
  3. Open Claude Code in that project and say “build Connect.AI”.

Set up your machine first

The finished product

/
Overview. Four stat tiles (Companies, Contacts, Emails sent, New senders) and the 6 most recently added companies
/find-companies
Find companies. Search Apollo by location, keywords and employee-size chips. Select results and click “Add N to companies”
/companies
Companies. Table of saved companies: name/domain, industry, employees, location, number of contacts, status
/companies/[id]
Company detail. Its contacts, a Find people panel (Apollo people search, then “Reveal & save”), and an Add by hand form
/contacts/[id]
Contact detail. Email, LinkedIn, status, outreach history
/outreach · /inbox-alerts
Placeholders. “Not built yet” — Phase 3 and Phase 4
/settings
Settings. Each integration (Apollo, Google OAuth, cron secret) as Configured or Missing

The stack

Next.js
Latest, App Router, TypeScript, src/ dir, @/* alias, with Tailwind CSS v4
shadcn/ui
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.

  1. 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.
  2. 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 not organizations/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.

01Step 1

Scaffold the app

  1. Run npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*".
  2. Delete the starter src/app/page.tsx and the stock SVGs in public/.
  3. Run npx shadcn@latest init, then npx shadcn@latest add checkbox sonner badge button input label table.
  4. Install the rest: npm i @prisma/client @prisma/adapter-pg pg and npm i -D prisma dotenv @types/pg.
  5. 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

  1. 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.
  2. Use the data model below. Status fields are plain strings, so the app can add states without a migration.
  3. 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.
  4. Run npx prisma migrate dev --name init, then npx prisma generate.
  5. 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.
  6. 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.

globals.css — the tokens

:root {
  --paper: #ffffff;  --paper-2: #f3f5f9;  --paper-3: #e9edf4;
  --navy: #000e2f;   --navy-soft: #2a3a5f;
  --red: #e4002b;    --red-deep: #b80022;
  --slate: #545454;  --silver: #a6a6a6;   --line: #dce2ec;
  --serif: "Times New Roman", Times, serif;
  --body: Calibri, "Segoe UI", system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --radius: 0.875rem;
}

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.

FunctionEndpointCostNotes
searchCompaniesPOST /mixed_companies/searchfreeSend 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.
searchPeoplePOST /mixed_people/api_searchfreeSend organization_ids or q_organization_domains_list, plus person_titles. First name, obfuscated last name, title and has_email. Never an email.
enrichOrganizationsByDomainPOST /organizations/bulk_enrich1 credit / matchMax 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.
revealPersonPOST /people/match1 creditReturns 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

  1. npm run build passes. It needs DATABASE_URL to be set, but doesn’t connect.
  2. npx tsc --noEmit passes — run it after the build, which generates the route types.
  3. npm run lint passes.
  4. 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.
Checking access…