
Description
Helps users understand and extend the Email Agent repo — an AI-powered outbound email generation app using Exa research, the Vercel AI SDK, and Outreach CRM. Use this skill when a user asks how the codebase works, how to hook up or swap an integration (Exa, OpenAI/AI models, Outreach, Neon Postgres), or how to add a feature. Loaded automatically for any question about this repository.
SKILL.md
Email Agent Repo Guide
This skill gives you deep knowledge of the Email Agent codebase so you can answer "how do I…" questions, explain the architecture, and guide extensions — without re-reading every file each time.
What This Repo Is
An AI-powered outbound email generator. A user creates a campaign (with a custom AI prompt + research toggles), uploads contacts via CSV or API, and each contact is processed through a durable Vercel Workflow that:
- Researches the company (and optionally the person) via Exa
- Generates a personalized email sequence via the Vercel AI SDK (Anthropic models)
- Optionally syncs the prospect to Outreach CRM and enrolls them in a sequence
Stack: Next.js 15 (App Router) · React Query · Vercel AI SDK · Drizzle ORM + Neon Postgres · Exa · Outreach · Tailwind 4 + shadcn/ui · Vercel Workflows.
Architecture Map
| Path | What lives here |
|---|---|
app/ | Next.js pages + API routes. / redirects to /campaigns. /settings has Outreach OAuth connect. |
app/campaigns/ | Campaign list, new, and [id] detail pages (SSR + React Query hydration). |
app/api/contacts/route.ts | POST /api/contacts — external entry point to add a contact + auto-start its workflow. |
app/api/outreach/callback/route.ts | OAuth2 callback for Outreach; exchanges code for tokens, upserts into oauth_tokens table. |
services/ | Server actions ('use server') for all internal DB operations. Called from client components via React Query. |
workflows/process-contact.ts | The durable workflow that orchestrates research → generation → Outreach for one contact. |
lib/research/ | Company + people research using Exa + AI summarization. |
lib/email/ | Email generation prompt builder + Zod output schema. |
lib/outreach/ | Outreach REST client, prospect upsert, sequence enrollment, sequence scaffolding. |
lib/exa.ts | Exa client singleton (reads EXA_API_KEY). |
lib/csv.ts | CSV parsing with header aliasing and validation (Zod not used here; manual validation). |
db/schema.ts | Drizzle schema: campaigns, contacts, oauth_tokens, settings + TypeScript types. |
db/index.ts | Lazy Neon/Drizzle proxy so the DB only initializes on first use. |
components/ | React components. shadcn/ui primitives in components/ui/. |
tests/ | Vitest integration tests. workflow.test.ts hits real Exa + AI; only Outreach is mocked. |
The Core Workflow
workflows/process-contact.ts is the heart of the app. Each contact runs through it independently and concurrently (Promise.all over start(processContactWorkflow, [id])).
pending → researching → generating → sending → completed (or failed)
Steps (each is a "use step" function for durability + retries):
- Load contact + campaign from DB (
stepLoadContactAndCampaign) —FatalErrorif missing - Research company via Exa → summarized by AI (
stepResearchCompany) — only ifcampaign.researchEnabled - Research person via Exa → verified by AI (
stepResearchPerson) — only ifcampaign.peopleResearchEnabled - Save research to
contacts.companyResearch/peopleResearch(JSONB) - Generate email via
generateTextwith structuredOutput.object(stepGenerateEmail) - Save email to
contacts.generatedSubject+generatedBody1/2/3 - Outreach (if
campaign.outreachMode !== 'none'): upsert prospect → set custom fields → optionally add to sequence - Mark completed (or
failedwith error message incontacts.data.error)
Workflow launch points:
services/contacts.ts:processAllContacts— "Process N pending" buttonservices/contacts.ts:addAndProcessContacts— after CSV uploadapp/api/contacts/route.ts— after API contact creation
External Integrations & APIs
Exa (research) — lib/exa.ts, lib/research/
- Env var:
EXA_API_KEY - Client:
exa-jsSDK, singleton viagetExaClient() - Calls:
exa.searchAndContents(query, { numResults, type: 'auto', category, summary: true }) - Company research (
lib/research/company.ts): two parallel searches (category: 'company'+category: 'news'), results summarized byanthropic/claude-haiku-4-5into{ companySummary, existingAIFeatures[] } - People research (
lib/research/people.ts): two parallel searches (profile + activity), AI verifiesisConfidentMatch(company+name must match) → returns{ title, contactSummary, recentActivity[] }ornull - Error handling: both research functions catch errors and return safe fallbacks (empty research / null) rather than failing the workflow
Vercel AI SDK (email generation + summarization) — lib/email/
- Env var:
AI_GATEWAY_API_KEY(Vercel AI Gateway) - Email generation (
workflows/process-contact.ts:stepGenerateEmail):generateText({ model: 'anthropic/claude-opus-4.6', output: Output.object({ schema }), prompt }) - Research summarization:
anthropic/claude-haiku-4-5withOutput.object+ Zod schema - Prompt builder:
lib/email/generation.ts:buildEmailGenerationPromptassembles sections: contact info, instructions (campaign system prompt), company research, people research, format requirements - Output schema:
lib/email/schema.ts:createEmailGenerationSchema(n)— dynamically builds Zod object withsubject+body1..bodyN(max 2 follow-ups = 3 bodies) - Default prompt:
DEFAULT_SYSTEM_PROMPTinlib/email/generation.ts— 3-email F-shaped sequence, ~75 words each, HTML<p>formatting
Outreach (CRM) — lib/outreach/
- Env vars:
OUTREACH_CLIENT_ID,OUTREACH_CLIENT_SECRET,OUTREACH_REDIRECT_URI - OAuth flow:
services/outreach.ts:getOutreachAuthUrl→ user authorizes atapi.outreach.io/oauth/authorize→ callback at/api/oauth/outreach/callback→ token exchange → stored inoauth_tokenstable - Token refresh:
lib/outreach/client.ts:refreshAccessTokenauto-refreshes if within 5 min of expiry - API base:
https://api.outreach.io, JSON:API format (Content-Type: application/vnd.api+json) - Custom fields: subject →
custom92, bodies →custom93/94/95(hardcoded inlib/outreach/prospects.tsandsequences.ts) - Endpoints used:
GET /api/v2/prospects?filter[emails]=…— find by emailPOST /api/v2/prospects— create prospectPATCH /api/v2/prospects/:id— set custom fieldsPOST /api/v2/sequenceStates— add prospect to sequencePOST /api/v2/sequences,/sequenceSteps,/templates,/sequenceTemplates—scaffoldOutreachSequencebuilds a full sequence
Neon Postgres — db/
- Env var:
DATABASE_URL - ORM: Drizzle (
drizzle-orm/neon-http), schema indb/schema.ts - Migrations:
pnpm db:push(push) orpnpm db:generate(generate SQL) - Tables:
campaigns,contacts(FK → campaigns, cascade delete),oauth_tokens,settings
How To Extend — Common Questions
"How do I hook this up to a different CRM (instead of Outreach)?"
Mirror the Outreach pattern:
- Create
lib/<crm>/client.ts— auth + authenticatedfetchwrapper (seelib/outreach/client.ts) - Create
lib/<crm>/prospects.ts—upsertProspect,setProspectCustomFields,addProspectToSequence - Add an OAuth callback route at
app/api/<crm>/callback/route.ts(see the Outreach callback) - Add a server action in
services/for generating the auth URL + checking connection - Add a
<crm>Modeenum todb/schema.tsalongside the existingoutreachModeenum, then branch inworkflows/process-contact.ts(the workflow already checkscampaign.outreachMode) - Add a settings UI component for the OAuth connect button
The workflow is the integration point — stepUpsertProspect, stepSetProspectFields, stepAddToSequence are isolated steps you can swap.
"How do I use a different research provider (instead of Exa)?"
- Replace
lib/exa.tswith a new client, or createlib/<provider>.ts - Rewrite
lib/research/company.tsandlib/research/people.tsto call your provider's search API - Keep the return types
CompanyResearchandPeopleResearch(defined indb/schema.ts) so the workflow and prompt builder don't need changes - Update the env var from
EXA_API_KEYto your provider's key
The research functions are self-contained — the workflow just calls researchCompany() / researchPerson() and saves the result.
"How do I use a different AI model?"
The model is a string passed to generateText({ model: '…' }). The app uses the Vercel AI Gateway, so any model the gateway supports works:
- Email generation:
anthropic/claude-opus-4.6inworkflows/process-contact.ts:222 - Research summarization:
anthropic/claude-haiku-4-5inlib/research/company.ts:53andlib/research/people.ts:70
Just change the model string. The Output.object({ schema }) structured output pattern works across providers.
"How do I add contacts from another source (webhook, CRM sync, etc.)?"
Two options:
- API route:
POST /api/contactsalready exists (app/api/contacts/route.ts). It takes{ campaignId, contact: { email, firstName, lastName?, companyName, context? } }and auto-starts the workflow. Point any external system at this endpoint. - Server action: Call
services/contacts.ts:addAndProcessContacts(campaignId, contactsData)directly from a server component or another server action.
"How do I add more follow-up emails?"
Currently capped at 2 follow-ups (3 total) by MAX_FOLLOW_UPS in lib/email/schema.ts. To increase:
- Bump
MAX_FOLLOW_UPS - Add
generatedBody4, etc. columns tocontactsindb/schema.ts→ runpnpm db:push - Add more Outreach custom field slots in
lib/outreach/prospects.ts(CUSTOM_FIELD_BODIES) andsequences.ts - Update
stepSaveEmailin the workflow to save the extra bodies - Update the campaign form select options in
components/campaign-form.tsx
"How do I add a new campaign setting?"
- Add the column to
campaignsindb/schema.ts→pnpm db:push - Add the form field in
components/campaign-form.tsx - Read it in
workflows/process-contact.ts(the campaign object is loaded instepLoadContactAndCampaign) - If it affects the prompt, pass it through
buildEmailGenerationPromptinlib/email/generation.ts
Coding Conventions (quick reference — full detail in README.md)
- Server actions (
services/,'use server') for internal data ops. API routes (app/api/) only for external entry points (OAuth, webhooks, third-party callers). - Vercel Workflows for anything calling external APIs or doing AI generation. Use
"use workflow"+"use step".FatalError= non-retryable; regularthrow= retryable. - React Query for all client data. Query keys centralized in
lib/query-keys.ts. Poll at 3s when contacts are processing. Pass SSR data asinitialData. - Drizzle types via
InferSelectModel/$inferInsert— don't duplicate. JSONB for complex nested data.onDelete: 'cascade'for children.withTimezone: truetimestamps. - Components: server components by default,
'use client'only when needed. shadcn/ui for primitives. NativeFormDatafor forms (no form libraries). - TypeScript: strict mode, prefer inference, validate untrusted data with Zod, no
any. - Tests: integration tests in
tests/with Vitest,fileParallelism: false, real Exa + AI calls (only Outreach mocked). UsecreateTracker()for cleanup.
Commands
pnpm dev # local dev server
pnpm build # production build
pnpm lint # next lint
pnpm test # vitest run (needs .env.local with real API keys + DATABASE_URL)
pnpm test:watch # vitest watch mode
pnpm db:push # push schema changes to Neon
pnpm db:generate # generate migration SQL
pnpm db:studio # Drizzle Studio GUI
pnpm workflows # inspect workflow runs (npx workflow inspect runs --web)
Environment Variables
| Var | Purpose |
|---|---|
AI_GATEWAY_API_KEY | Vercel AI Gateway — required for all AI calls |
EXA_API_KEY | Exa search API — required for research |
DATABASE_URL | Neon Postgres connection string |
OUTREACH_CLIENT_ID | Outreach OAuth (optional — only if using Outreach) |
OUTREACH_CLIENT_SECRET | Outreach OAuth (optional) |
OUTREACH_REDIRECT_URI | Must be https://<domain>/api/oauth/outreach/callback |
NEXT_PUBLIC_SITE_URL | Used for the API curl example on campaign detail page |
Answering Questions
When a user asks "how do I…" about this repo:
- Identify which integration/layer they're asking about (research, AI, CRM, DB, UI, workflow).
- Reference the exact files above so they can navigate directly.
- If they want to swap an integration, point them to the "How To Extend" section that matches and name the specific files to change.
- If unsure about current behavior, read the specific file before answering — this guide is a map, not a substitute for the code.
More from Vercel Labs
View publisheragent-browser
automate browser interactions for AI agents
agent-browser
Jul 20AgentsAutomationBrowser Automationagentcore
run browser automation on AWS Bedrock
agent-browser
Jul 17AutomationAWSBrowser Automationcore
navigate and interact with web pages
agent-browser
Jul 26AgentsBrowser AutomationNavigationderive-client
reverse engineer internal APIs from browser traffic
agent-browser
Jul 20API DevelopmentAutomationBrowser AutomationWeb Scrapingdogfood
perform exploratory testing on web applications
agent-browser
Jul 17Browser AutomationDebuggingQATestingelectron
automate Electron desktop applications
agent-browser
Jul 17AgentsBrowser AutomationDesktop