A lightweight outreach CRM for managing cold email campaigns with AI-powered research and drafting.
Core workflow: Import CSV → Enrich (Exa) → Draft (Claude) → Review/Edit → Send (Gmail) → Track Replies
| Layer | Technology |
|---|---|
| Framework | TanStack Start (React, file-based routing, SSR) |
| Runtime | Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| Background Jobs | Cloudflare Queues |
| AI | Anthropic Claude API |
| Research | Exa API |
| Gmail API (OAuth2) | |
| Auth | Better Auth (invite-only signups) |
| Styling | Tailwind CSS v4 + shadcn/ui |
- Node.js 20+
- pnpm 9+
- Wrangler CLI (for local D1 and deployment)
# Clone and install dependencies
git clone <repo-url>
cd pluto
pnpm install
# Generate Cloudflare types
pnpm cf-typegen
# Set up local database
pnpm db:migrateCreate a .dev.vars file for local development secrets:
ANTHROPIC_API_KEY=sk-ant-...
EXA_API_KEY=...
GMAIL_CLIENT_ID=...
GMAIL_CLIENT_SECRET=...
BETTER_AUTH_SECRET=generate-a-random-32-char-stringFor production, set secrets via Wrangler:
wrangler secret put ANTHROPIC_API_KEY
wrangler secret put EXA_API_KEY
wrangler secret put GMAIL_CLIENT_ID
wrangler secret put GMAIL_CLIENT_SECRET
wrangler secret put BETTER_AUTH_SECRETpnpm devOpen http://localhost:3000.
Pluto uses invite-only signups:
- First user to sign up becomes the admin automatically
- After that, the signup page is closed to the public
- Admin invites new members from Settings > Team, which generates an invite link (
/signup?token=xxx) - Invited users click the link to create their account
- Invite links expire after 7 days and can be revoked
Users have a role field — either admin or member. The admin can manage team members and revoke access from the Settings page.
| Command | Description |
|---|---|
pnpm dev |
Start dev server on port 3000 |
pnpm build |
Build for production |
pnpm test |
Run Vitest tests |
pnpm deploy |
Build and deploy to Cloudflare |
pnpm cf-typegen |
Regenerate Cloudflare runtime types |
pnpm db:generate |
Generate Drizzle migration from schema changes |
pnpm db:migrate |
Apply migrations to local D1 |
pnpm db:migrate:prod |
Apply migrations to production D1 |
src/
├── routes/ # File-based routing (TanStack Router)
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Dashboard
│ ├── review.tsx # Draft review queue
│ ├── contacts/ # Contact management
│ ├── companies/ # Company management
│ └── campaigns/ # Campaign management
├── components/
│ ├── ui/ # shadcn/ui components
│ └── *.tsx # App components
├── lib/
│ ├── db/
│ │ ├── schema.ts # Drizzle schema (all tables)
│ │ └── index.ts # Database helper
│ ├── server/ # Server functions (createServerFn)
│ ├── queue/
│ │ ├── types.ts # Job message types
│ │ └── processors.ts # Queue job handlers
│ ├── env.ts # Cloudflare env access
│ └── utils.ts # Utilities (cn, etc.)
├── worker.ts # Cloudflare Worker entry point
└── router.tsx # Router configuration
Create a file in src/routes/. TanStack Router auto-generates route types.
// src/routes/example.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/example')({
component: ExamplePage,
})
function ExamplePage() {
return <div>Example</div>
}Dynamic routes use $param syntax: src/routes/contacts/$id.tsx
Server functions run on Cloudflare Workers. Use createServerFn from TanStack Start:
// src/lib/server/example.ts
import { createServerFn } from "@tanstack/react-start";
import { getEnv } from "@/lib/env";
import { getDb } from "@/lib/db";
export const getExample = createServerFn({ method: "GET" })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const env = getEnv();
const db = getDb(env.DB);
// Query database
const result = await db.query.examples.findFirst({
where: eq(examples.id, data.id),
});
return result;
});
export const createExample = createServerFn({ method: "POST" })
.inputValidator((data: { name: string }) => data)
.handler(async ({ data }) => {
const env = getEnv();
const db = getDb(env.DB);
const [created] = await db
.insert(examples)
.values({ name: data.name })
.returning();
return created;
});Call from components:
// In a route or component
const data = await getExample({ data: { id: "123" } });Schema is defined in src/lib/db/schema.ts using Drizzle ORM.
Core tables:
products- Product configurations with prompt templatescompanies- Company records with enrichment datacontacts- Contact records linked to companiescampaigns- Campaign configurationscampaign_contacts- Junction table with pipeline stateemails- Email history and trackingactivities- Audit loggmail_tokens- OAuth token storageinvites- Invite tokens for new user signupsusers/sessions/accounts- Better Auth tables
Modifying the schema:
- Edit
src/lib/db/schema.ts - Generate migration:
pnpm db:generate - Apply locally:
pnpm db:migrate - Apply to production:
pnpm db:migrate:prod
Each contact in a campaign moves through stages:
new → queued_enrich → enriching → enriched → queued_draft → drafting → drafted → approved → queued_send → sending → sent
↓
replied | bounced | skipped
Stage transitions are managed by:
- UI actions - User approves/skips drafts
- Queue processors - Background jobs for enrichment, drafting, sending
The worker (src/worker.ts) processes queue messages. Job types:
| Type | Purpose | Processor |
|---|---|---|
enrich |
Fetch company research via Exa | processEnrichment |
draft |
Generate email via Claude | processDrafting |
send |
Send email via Gmail | processSending |
check_replies |
Poll for email replies | processReplyCheck |
Enqueue a job:
await env.JOBS_QUEUE.send({
type: "enrich",
campaignContactId: cc.id,
campaignId: campaign.id,
});This project uses shadcn/ui. Add components via CLI:
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialogComponents are added to src/components/ui/.
Icons:
- Primary:
@hugeicons/react-import { IconName } from "@hugeicons/react" - Secondary:
lucide-react-import { IconName } from "lucide-react"
Tests use Vitest with setup in test/setup.ts.
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test --watch
# Run specific test file
pnpm test src/lib/example.test.tsTest files should be co-located: example.ts → example.test.ts
Used for company enrichment. See src/lib/server/enrichment.ts.
// Multi-query enrichment with company info + recent news
const { companyResults, newsResults } = await enrichWithMultiQuery(
companyName,
productQuery,
env.EXA_API_KEY
);Used for email generation. See src/lib/server/drafting.ts.
The drafting pipeline:
- Extract a "hook" from enrichment data
- Build structured prompt with product context
- Generate personalized email (<150 words)
OAuth flow and email sending. See src/lib/server/gmail*.ts.
gmail-auth.ts- OAuth token managementgmail-api.ts- Raw Gmail API callsgmail.ts- High-level send/reply functions
Deploy to Cloudflare:
pnpm deployThis builds the app and deploys via Wrangler. Ensure production secrets are set first.
- Set all secrets via
wrangler secret put - Run
pnpm db:migrate:prodfor schema changes - Verify queue bindings in
wrangler.jsonc
Why TanStack Start?
- File-based routing with type-safe server functions
- SSR support with Cloudflare Workers compatibility
- Modern React 19 features
Why Cloudflare D1?
- SQLite at the edge with zero cold starts
- Integrated with Workers runtime
- Cost-effective for small teams
Why Cloudflare Queues?
- Reliable background job processing
- Automatic retries with dead-letter queue
- Native Workers integration
Why separate enrichment → drafting stages?
- Allows review of research quality before drafting
- Enables batch operations at each stage
- Provides clear audit trail