&Anindo Neel Dutta
HomeCase StudiesSpeakingBlogNotes
&Anindo Neel Dutta
HomeCase StudiesSpeakingBlogNotes
&Anindo Neel Dutta
HomeCase StudiesSpeakingBlogNotes
GO BACK
Project

Peergrowth Pulse: Meta Ads Intelligence That Shares One Source of Truth

Timeline4-Month Build
Metric~2,000 leads processed
Date12th Aug, 2026
Core Stack
Next.js
Clerk
Drizzle
Postgres
Trigger.dev
Gemini

The context

Peergrowth Pulse is Meta-first growth intelligence software I designed and shipped end to end. Live Marketing API analytics, a synced Instant Form lead pipeline, capacity-aware attribution, multi-tenant agency workspaces, a tool-grounded Gemini Copilot, and shareable client reports that freeze their numbers at send time.

I built it while working with Peergrowth, for the operational loop their Meta work actually ran on. Not a demo dashboard. Not a generic "AI ads" wrapper.

The product exists because Meta lead-gen work spreads across surfaces that do not agree with each other. Ads Manager holds spend and delivery. Instant Forms hold who submitted. Someone still has to pass leads around and update status. Client reporting means reassembling those sources into a deck. "Why did CPL move?" is a separate investigation every time.

The hard part was never missing data. Meta has metrics. Forms have submissions. The hard part was the workflow: acquisition -> lead identity -> CRM state -> analysis -> something a client can trust later.

A naive product would either mirror Ads Manager or paste a CSV into a chatbot. Both fail in production. The first ignores lead quality and reporting integrity. The second invents numbers with confidence.

What I delivered

Pulse is invite-only software for Meta lead-gen and sales-oriented ad accounts. The operator loop is:

  1. Connect Meta (Facebook OAuth via Clerk -> Marketing API token)
  2. Work live performance (campaigns, ad sets, creatives, funnel, targets)
  3. Sync Instant Form leads into a durable pipeline (status, notes, custom attributes)
  4. Inspect attribution and quality, not just Meta CPL
  5. Ask the Copilot questions against live Meta + Postgres
  6. Generate a frozen client report link (/r/[token])

The actions that actually matter day to day: triaging leads without losing CRM edits on the next Meta sync, creating a client report that will not silently change tomorrow, and asking "what moved?" and getting tool-backed numbers.

The core idea

One analytics surface, two consumers, and a third clock for clients.

I did not want three different truths:

  1. Dashboard truth (live Meta)
  2. Copilot truth (whatever the model invents)
  3. Client-report truth (whatever was screenshotted last)

So I built composable analytics primitives (queryMetaInsights, comparePeriods, queryLeads, getAttributionFunnel, analyzeCohort, detectChanges) and reused them. Copilot exposes them as read-only tools. Reports call the same modules directly: deterministic gather -> one narrative pass -> freeze. The dashboard uses the same Meta and Postgres sources underneath.

If AI and reporting do not share the same analytics path as the UI, you do not have a system. You have three demos.

The engineering insight underneath that product decision: ads insights and CRM leads cannot share one freshness strategy. Live Meta for delivery. Durable Postgres for lead workflow. Frozen snapshots for clients.

That idea is the same one I argue in I Don't Want Your AI Copilot: the chat box cannot be the product. Pulse has a Copilot on purpose. The product already knows the campaign data, lead state, and attribution. The model has to reason over that through tools, not invent CPL from vibes. A copilot should be an interface into product intelligence, not a substitute for it.

Architecture

Rendering diagram

Stack: Next.js 16 / React 19, Clerk, Drizzle + Postgres, Trigger.dev, Vercel AI SDK + Gemini, Sentry.

Auth path: Clerk session -> org/ad-account ACL -> owner Facebook token -> Meta Graph.

Engineering highlights

Two clocks of data freshness

Ads KPIs must feel current. Instant Form leads need durable operator state (status, notes, tags) that survives re-fetch and re-sync.

Caching everything in Postgres makes spend look stale. Fetching everything live on every page load makes the CRM useless. So performance surfaces call Meta live (Graph fetch layer with retries and Next revalidation). Leads sync on Trigger.dev (hourly for leads-goal accounts, plus first-setup/manual), upsert into Postgres, and omit workflow fields, notes, and custom attributes from conflict updates so CRM work is not wiped by Meta.

Operators get an honest split instead of a fake single cache. Lead freshness depends on sync health. Ads UI depends on Meta availability and rate limits.

Attribution when Meta and forms disagree

Form leads often include ad/campaign fields. Meta also reports lead actions at ad × day. Those counts are not always 1:1: organic submissions, missing ad_id, over-subscription.

Trusting every form lead with an ad_id as attributed looks clean and lies. Pulse capacity-matches chronologically: for each adId + account-timezone day, assign attributed only while assigned count is below Meta's reported lead actions; otherwise not_attributed. Organic and missing ad IDs map to organic / unknown. Separately, an attribution funnel joins live Meta delivery with CRM aggregates (qualified/won rates).

This is not multi-touch attribution. Some joins fall back to names where IDs are incomplete. Ambiguity is visible instead of hidden inside a pretty CPL.

Multi-tenant isolation that survives a missed WHERE

Agency workspaces, teammates, and client ad accounts cannot leak leads or settings across tenants. Filtering by userId in application code and hoping every query remembers is how you get a breach later.

Pulse uses Postgres organizations, membership roles, and per-ad-account ACL. Request-path DB access goes through withUserDb, which sets app.clerk_user_id for row-level security. Meta Marketing API tokens come from the workspace owner's Clerk Facebook OAuth; teammates operate on granted accounts under that ownership model.

Dual DB roles and FORCE RLS add operational complexity. Application asserts remain necessary. RLS is defense in depth, not a substitute for product ACL logic.

AI that is not allowed to invent CPL

In I Don't Want Your AI Copilot I wrote that if you ask Pulse why CPL increased, you should not get a generic essay about the five things that can move CPL. You should get reasoning over the data Pulse already has. The user should not paste campaign data into a prompt. The product already knows.

That is the standard the Copilot had to meet.

LLMs will invent ROAS and CPL if you stuff a JSON blob into a prompt and ask for analysis. "AI-powered insights" over a dashboard dump is how you get confident nonsense in a client conversation. Pulse uses Gemini 2.5 Flash with 11 read-only tools over the same analytics modules the dashboard and reports already use. The system prompt requires tools for all numbers. Compact DTOs strip PII (no names, emails, or phones). Rate limit is 20 requests/hour/user. Briefs and reports use structured generation. suggest_lead_follow_up suggests. It does not write lead state.

That also maps to the exploration vs execution split in that post. "Why did CPL move this week?" is exploration over unfamiliar variance. Chat earns its keep there, as long as every number comes from compare_periods, detect_changes, query_meta_insights, or the rest of the tool set. Syncing leads, capacity-matching attribution, and freezing a client report are execution. Those stay in product workflows. Nobody should prompt their way through a report.

The cost is tool-calling latency and Meta/DB dependency. The in-memory rate limit and brief cache are fine for current scale, not the multi-instance story. Prompt polish still has debt, including currency framing that hardcodes INR in places.

Client reports that do not move after send

Live Meta numbers change. A "live link" turns yesterday's client conversation into today's argument.

Sharing a logged-in dashboard view, or regenerating on every open, fails that trust test. Pulse freezes once: gather -> one narrative pass -> persist JSON snapshot + unguessable token -> public /r/[token] reads the snapshot. Revoke and restore without rewriting history. No multi-step agent loop at report creation time.

Snapshots diverge from current Ads Manager by design. Report create needs a successful gather and narrative at that moment. That is the point.

Syncing Meta without destroying operator work

Meta rate limits, pagination, Page tokens for leadgen, and long first backfills do not fit a naive request-path sync. Syncing leads inside the Next.js request that opens the leads page is how you hang the page and lose state.

Trigger.dev tasks handle it (maxDuration up to an hour for sync), with an hourly scheduler, idempotency keys per account-hour, skip-if-in-flight tags, first sync of about 90 days, and retries on Graph rate limits and transients. Sync health surfaces stale or failed state in the UI.

Scheduled sync targets leads-goal accounts. Sales-goal accounts do not get the same hourly lead pull by design.

Product decisions

These are decisions visible in the product shape, not marketing slogans.

  1. Meta-only. Depth for Facebook/Instagram Instant Forms and Marketing API over shallow multi-channel coverage. Google Ads is out of scope on purpose.
  2. Own the lead workflow, not "be Salesforce." Statuses, notes, select/text attributes, sync health. HubSpot/Zoho-style connectors are deferred, not faked.
  3. Leads vs sales goal per ad account. Different KPIs, report sections, and sync targeting. Same product, two operator modes.
  4. Clients get a link, not an account. Public frozen reports reduce client onboarding friction; the team keeps the workspace.
  5. Copilot is read-only, and not the whole product. Analysis and suggestions are fine. Silent CRM mutation is not. Chat is an interface into the analytics layer for exploratory questions. Execution (sync, attribution, freeze-once reports) stays in workflows the product owns. See I Don't Want Your AI Copilot.
  6. Surface attribution ambiguity. attributed / not_attributed / organic / unknown instead of pretending every form lead is clean.

What I learned

  1. The hardest AI problem was permissioning the model's world, not picking a model. Once tools, compaction, and "no invented numbers" were non-negotiable, Gemini Flash was an implementation detail.

  2. Freshness is a product decision dressed up as caching. Treating ads and leads as one data class creates either stale spend or wiped CRM state. Splitting the clocks was the real design.

  3. Attribution honesty beats attribution theater. Capacity-matching is imperfect. Hiding the mismatch inside a single CPL is worse.

  4. Reports are a consistency problem. Freeze-once snapshots are less "AI magic" and more distributed-systems thinking applied to client trust.

  5. Reusable analytics primitives pay for themselves twice. The same modules feeding Copilot tools and report gather prevented the classic drift where chat and PDF disagree.

What I'd do differently

  1. Replace in-memory AI rate limits and brief cache with a shared store before multi-instance becomes real. The code already comments that direction.
  2. Clean prompt assumptions (currency framing) so grounding rules and account settings always agree.
  3. Tighten attribution joins where IDs are incomplete (for example missing adset_id on stored leads) instead of leaning on names in places.
  4. Add a real automated test suite beyond scripts. RLS cross-tenant and smoke scripts exist; unit/e2e coverage does not. That is underbuilt relative to the tenancy and AI surface area.
  5. I would not undo RLS, freeze-once, or tool grounding. Those constraints are the product.

Results

OutcomeResult
Leads processed through Pulse~2,000
Manual lead-passing workflowReplaced
Manual client-report creationReplaced
Report creation timeAt least a few hours -> ~1 minute
Engineering ownershipEnd-to-end product/engineering (git history: sole author commits)
Production system shippedLive Meta analytics + lead sync + reports + Copilot

What I am not claiming here: revenue or retention impact, number of external client accounts, sync success rate or p95 duration, Copilot satisfaction or question volume, exact total hours saved across the agency, or a percentage speedup. "A few hours" is not a precise baseline, and I am not going to invent one.

Technical appendix

LayerChoice
AppNext.js 16.1 App Router, React 19
AuthClerk (Google + Facebook); Facebook token -> Meta Marketing API
DBPostgres + Drizzle; RLS (app_user / app_admin)
JobsTrigger.dev: scheduled-lead-sync (0 * * * *), sync-meta-leads
AIgemini-2.5-flash, AI SDK streamText / generateObject
ObservabilitySentry (+ Vercel AI integration), structured AI logs, layout perf budgets

Copilot tools (11, read-only): get_ad_overview, query_meta_insights, compare_periods, query_leads, get_attribution_funnel, analyze_cohort, detect_changes, get_account_targets, get_sync_health, get_lead_detail, suggest_lead_follow_up

ConcernWhere
Meta Graph client / retrieslib/meta/meta-api.ts
Live ads domain fetcheslib/meta-services/*
Lead sync + upsertslib/leads/meta-leads.server.ts, jobs/*
Attributionlib/meta-services/lead-attribution.ts
Shared analyticslib/analytics/*
Copilot tools / promptlib/ai/tools.server.ts, lib/ai/prompt.server.ts
Freeze-once reportslib/reports/report.server.ts, lib/reports/report-data.server.ts
Tenancy + RLS entrylib/ad-account/*, db/index.ts withUserDb

Invite-gated signup. Org + ad-account ACL. Postgres RLS. PII stripped before the model. Public reports omit contact fields. Copilot cannot mutate lead state.


Related: I Don't Want Your AI Copilot · Why Most AI Products Don't Need Agents · DocPilot case study

Need a validation-ready MVP shipped in weeks?

Get in touch