Build a Self-Running AI Networking Engine

Deploy a multi-agent pipeline that auto-sends personalized follow-ups.

Introduction

30 Second Summary

You meet someone brilliant at a conference or meetup. By the time you get around to following up, two weeks have passed and the connection is dead.

In this project, you will build a self-running AI networking engine for a real client named Marcus. The system captures contacts via QR code at events, drafts personalized follow-ups with Claude, and auto-sends the email the moment Marcus approves from his phone.

What You'll Build

A fully automated networking pipeline where a QR scan captures contacts, AI drafts personalized follow-ups, your client approves from their phone, and a database trigger sends the email instantly.

By the end of this project, you'll have:

  • A bot-protected landing page on Cloudflare Pages with Cloudflare Turnstile that captures contacts at conferences and meetups.
  • A Supabase CRM with Row Level Security and a database trigger that auto-sends emails via Resend on approval.
  • A 25-agent Connection Council in Obsidian that drafts personalized follow-ups and improves its templates every week.
  • Secret Mission: Build a PWA-installable approval dashboard with real-time updates and a self-healing health monitor.

Are there any prerequisites?

This project assumes you already have a Claude Max 20x subscription ($200/mo) for the 25-agent system. All other infrastructure — Cloudflare Pages, Supabase, Resend, and Cloudflare Turnstile — runs on free tiers with no charges.

You'll also need Node.js, Git, and Obsidian installed locally, plus free accounts on Cloudflare, Supabase, Resend, and GitHub.

Create Pre-Build Artifacts

Before writing a single line of application code, you need a blueprint. Architecture Decision Records (ADRs) capture why you chose each tool so that future-you (or Marcus) never has to reverse-engineer your reasoning.

In this step, you will document 6 architecture decisions, draw a full system diagram in Mermaid, and map out a parallel execution plan that lets Claude agents build 4 tracks simultaneously.

In this step, get ready to:
  • Write 6 Architecture Decision Records covering every major tool choice.
  • Generate a Mermaid architecture diagram showing the full data-flow from QR scan to email delivery.
  • Document a 4-track parallel execution plan for the remaining build steps.
Write Architecture Decision Records

An ADR is a short document that records a technology choice along with the context, consequences, and cost to reverse it. These survive long after the build is done. When Marcus (or another developer) asks "why Supabase and not Firebase?" the answer lives in a file, not in your memory.

  • Create the folder structure for your Council vault by running these commands in your project folder:
mkdir -p Council/adrs

What is this folder structure?

The Council/ folder is the root of your Obsidian vault. You will open this as a vault in Obsidian shortly. The adrs/ subfolder holds all six decision records.

  • Open Obsidian by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Obsidian and press Enter to open it.
  • In the vault switcher, click Open folder as vault.
  • Navigate to and select the Council folder you just created, then click Open.

What does opening a folder as a vault do?

Obsidian adds a hidden .obsidian configuration folder inside your Council directory. Your existing folders and files stay untouched. From now on, every markdown file you create here shows up in Obsidian's sidebar.

  • In Obsidian, open the adrs folder in the sidebar and create a new file called adr-001-cloudflare-pages.md.
  • Paste the following content into the file:
# ADR-001: Cloudflare Pages over Vercel

## Context

Marcus's landing page needs global CDN hosting that is free for commercial use, fast on mobile, and low-maintenance for 5+ years. Two leading options: Cloudflare Pages and Vercel.

## Decision

Use Cloudflare Pages (Free tier).

- Unlimited sites, requests, and bandwidth on the free plan.
- Commercial use explicitly allowed.
- Same-vendor ecosystem: Workers, Turnstile, Analytics, and DNS all integrate natively.
- Vercel Hobby plan restricts usage to non-commercial personal projects only.

## Consequences

- Positive: Zero hosting cost at any traffic level. Single vendor for CDN, serverless, bot protection, and analytics.
- Negative: Fewer framework-specific optimizations compared to Vercel (not relevant for static HTML).

## Reversal Cost

Low. Static HTML deploys anywhere. Swap the Git integration to Vercel or Netlify in under 10 minutes. Only the Worker (keep-alive cron) needs a new home.

What does this ADR establish?

This records the hosting decision with hard evidence. The key fact: Vercel Hobby is non-commercial only, while Cloudflare Pages Free allows commercial use with unlimited bandwidth. Since Marcus's system is a professional tool, Vercel is off the table.

  • Save the file in Obsidian.
  • You should see adr-001-cloudflare-pages.md appear under the adrs folder in the sidebar.
  • Create a new file called adr-002-supabase.md in the adrs folder and paste this content:
# ADR-002: Supabase over Firebase

## Context

The system needs a database for visitors, drafts, sends, and responses. It must support Row Level Security, async HTTP calls from triggers (to auto-send emails on approval), real-time subscriptions, and multi-tenant indexing.

## Decision

Use Supabase (Free tier).

- Full Postgres with Row Level Security policies.
- pg_net extension enables async HTTP from inside triggers (no external webhook service needed).
- Realtime subscriptions push new drafts to the client dashboard without polling.
- REST API with anon/service-role keys for direct client-side INSERTs.
- Free tier: 500 MB database, 50k MAU, unlimited API requests.

## Consequences

- Positive: Single service handles CRM storage, access control, auto-send trigger, and live updates.
- Negative: Free tier pauses after 1 week of inactivity (solved by keep-alive Worker).

## Reversal Cost

Medium. Postgres is portable, but the pg_net trigger and RLS policies are Supabase-specific. Migration requires rewriting the auto-send logic as an external webhook handler.
  • Save the file.
  • Create adr-003-claude-only.md in the adrs folder and paste this content:
# ADR-003: Claude-Only over Dual-Model

## Context

The Connection Council needs an LLM for drafting follow-ups, refining templates, and running compliance checks. Options: Claude-only (via Max 20x subscription) or a dual-model approach mixing providers.

## Decision

Use Claude exclusively (Max 20x at $200/mo).

- Single-provider simplifies prompt engineering, context management, and billing.
- Max 20x provides 20x more usage than Pro, enough for all 25 agents plus daily drafting.
- Sonnet 4.6 for complex reasoning (refinement, compliance). Haiku 4.5 for volume drafting.
- MCP integration via Obsidian filesystem gives Claude direct access to the vault.

## Consequences

- Positive: One billing relationship. Consistent voice across all agents. No provider-switching logic.
- Negative: Single point of failure if Anthropic has an outage. No cost arbitrage with cheaper models.

## Reversal Cost

Low. Prompts are plain markdown. Any LLM can read them. The MCP integration is Claude-specific but the vault content is portable.
  • Save the file.
  • Create adr-004-static-html.md in the adrs folder and paste this content:
# ADR-004: Static HTML over Framework

## Context

The QR landing page must load fast on mobile at conferences, remain functional for 5+ years without maintenance, and deploy with zero build steps.

## Decision

Use static HTML with Tailwind CSS CDN.

- Zero build step. No webpack, no bundler, no node_modules.
- No framework dependency rot. React, Next.js, and Vite all release breaking changes yearly.
- Deploys in seconds to any CDN. Lighthouse mobile score 95+ achievable.
- Tailwind Play CDN provides utility classes without a build pipeline.

## Consequences

- Positive: 5-year durability with zero maintenance. Fastest possible load time. Any developer can edit.
- Negative: No component reuse. No SSR or dynamic routing (not needed for a single landing page).

## Reversal Cost

Low. A single HTML file migrates into any framework as a template in minutes.
  • Save the file.
  • Create adr-005-obsidian-vault.md in the adrs folder and paste this content:
# ADR-005: Obsidian Vault over Hosted CRM

## Context

The system needs a workspace for agent prompts, brand voice configuration, templates, and execution logs. Options: hosted CRM (HubSpot, Notion), or a local Obsidian vault with filesystem MCP.

## Decision

Use an Obsidian vault on the operator's machine.

- Operator owns all data locally. No vendor lock-in. No subscription.
- Filesystem MCP connects Claude directly to vault files for reading and writing.
- Markdown is future-proof and version-controllable with Git.
- Obsidian's graph view helps visualize agent relationships.

## Consequences

- Positive: Zero cost. Full data ownership. Claude reads/writes via MCP without API keys.
- Negative: No multi-user collaboration (not needed for a single operator). No cloud sync unless added.

## Reversal Cost

Low. Every file is plain markdown. Export to Notion, a CRM, or a database at any time with a simple script.
  • Save the file.
  • Create adr-006-turnstile.md in the adrs folder and paste this content:
# ADR-006: Cloudflare Turnstile over reCAPTCHA

## Context

The landing page form needs bot protection. Conference attendees should not encounter friction. Options: Google reCAPTCHA or Cloudflare Turnstile.

## Decision

Use Cloudflare Turnstile (Free, Managed mode).

- Free with no request limits.
- WCAG 2.2 AA compliant (accessible to all attendees).
- Managed mode auto-decides challenge level. Invisible to most real visitors.
- Same vendor as Pages, Workers, and DNS. No additional account needed.
- Privacy-preserving: no tracking cookies, no data sold to advertisers.

## Consequences

- Positive: Zero friction for real conference attendees. Zero cost. Same-vendor integration.
- Negative: Less market share than reCAPTCHA (smaller ecosystem of troubleshooting resources).

## Reversal Cost

Low. Swap the Turnstile script tag and widget div for reCAPTCHA equivalents. Update server-side validation endpoint. Under 30 minutes of work.

What do these 6 ADRs cover?

Together, these records document every major tool choice: hosting (Cloudflare Pages), database (Supabase), AI provider (Claude), frontend approach (static HTML), workspace (Obsidian), and bot protection (Turnstile). Each records the reasoning so Marcus or any future developer can understand your decisions without needing you.

  • Save adr-006-turnstile.md.
  • Confirm all 6 files exist in the sidebar under adrs/. You should see adr-001 through adr-006 listed.

Don't see all 6 files in the sidebar?

Make sure each file is saved (check for the dot indicator on the tab in Obsidian). If a file is missing, right-click the adrs folder in the sidebar and select New note, then rename it to the correct filename.

Still having trouble organizing the vault?

Generate the Mermaid architecture diagram

A system diagram makes the entire data-flow visible in one glance. You will use Mermaid syntax, which Obsidian renders natively as a live diagram.

  • In Obsidian, create a new file called architecture.md at the root of your Council vault (not inside adrs/).
  • Paste the following Mermaid diagram:
# System Architecture

Complete data-flow from QR scan to email delivery, including self-healing and second-touch paths.

```mermaid
flowchart TD
    %% Entry Point
    QR["QR Code Scan\n(UTM-attributed)"]
    LP["Landing Page\n(Cloudflare Pages)"]
    TN["Turnstile\n(Bot Protection)"]

    %% Data Capture
    SB_INSERT["Supabase INSERT\n(visitors table)"]

    %% AI Drafting
    COUNCIL["Claude Connection Council\n(C1: Follow-Up Drafter)"]
    SENDS["sends table\n(status: pending)"]

    %% Client Approval
    DASH["Client Dashboard\n(approve / edit / reject)"]
    APPROVED["status: approved"]

    %% Auto-Send
    TRIGGER["pg_net BEFORE UPDATE trigger"]
    RESEND["Resend API\n(email delivery)"]
    SENT["status: sent"]

    %% Response Tracking
    RESPONSES["responses table\n(reply tracking)"]

    %% Refinement
    E5["E5: Weekly Refinement\n(template performance)"]

    %% Second Touch
    STALE["stale_sends view\n(no reply after 7 days)"]
    C4["C4: Second-Touch Drafter\n(value-add message)"]

    %% Infrastructure
    WORKER["Keep-Alive Worker\n(daily cron)"]
    HEALTH["/health endpoint\n(component checks)"]

    %% Main Flow
    QR --> LP
    LP --> TN
    TN -->|verified| SB_INSERT
    SB_INSERT --> COUNCIL
    COUNCIL --> SENDS
    SENDS --> DASH
    DASH -->|approve| APPROVED
    APPROVED --> TRIGGER
    TRIGGER --> RESEND
    TRIGGER --> SENT
    RESEND --> RESPONSES

    %% Refinement Loop
    RESPONSES --> E5
    E5 -->|improves| COUNCIL

    %% Second Touch Path
    SENT --> STALE
    STALE --> C4
    C4 --> SENDS

    %% Infrastructure Connections
    WORKER -->|pings| SB_INSERT
    HEALTH -->|checks| WORKER
    HEALTH -->|checks| RESEND
    HEALTH -->|checks| SB_INSERT
```

## Key Paths

- **Main flow**: QR > Turnstile > Supabase > Claude drafts > Client approves > pg_net auto-sends > Resend delivers
- **Refinement loop**: Response data feeds E5 weekly, which improves Council drafting quality
- **Second touch**: Sends with no reply after 7 days get flagged, C4 drafts a value-add follow-up
- **Self-healing**: Keep-alive Worker prevents Supabase pause. Health endpoint monitors all components.

What does this diagram show?

The main flow runs top-to-bottom: a QR scan triggers a visitor INSERT, Claude drafts a follow-up, Marcus approves it, and a database trigger auto-sends it via Resend. No manual API calls needed after approval.

The side paths show the self-improvement loop (E5 refines templates based on reply data), the second-touch path (C4 drafts a value-add for non-responders at Day 7), and the self-healing infrastructure (Worker cron + health endpoint).

  • Save architecture.md.
  • Switch to Obsidian's Reading view by clicking the book icon in the top-right corner of the editor. You should see the Mermaid diagram rendered as a flowchart with boxes and arrows.

Seeing raw code instead of a diagram?

Make sure you are in Reading view, not Editing view. Obsidian only renders Mermaid diagrams in Reading view. Also confirm the opening line is exactly ```mermaid with no spaces before the backticks.

Help me troubleshoot my Mermaid diagram in Obsidian.

Document the parallel execution plan

The remaining build steps can run as 4 independent tracks. Documenting this upfront means Claude agents can work on the landing page, database, email, and vault simultaneously without blocking each other.

  • In Obsidian, create a new file called execution-plan.md at the root of your Council vault.
  • Paste the following content:
# Parallel Execution Plan

Four independent tracks converge at Step 7 (end-to-end test). Each track can be built by a separate Claude agent without blocking the others.

## Track A: Landing Page + Turnstile + Deploy

**Owner**: Agent A (Landing)
**Steps**: 2-3
**Deliverables**:
- Mobile-first HTML with Tailwind CSS
- Cloudflare Turnstile widget integrated
- Form wired to Supabase REST API with UTM capture
- Deployed to Cloudflare Pages with HTTPS
- Lighthouse mobile score 95+

**Dependencies**: Supabase project URL and anon key (from Track B)

## Track B: Supabase Schema + RLS + pg_net + Second-Touch

**Owner**: Agent B (Database)
**Step**: 4
**Deliverables**:
- Tables: visitors, events, sends, responses
- Row Level Security policies (anon INSERT only, authenticated reads own data)
- pg_net BEFORE UPDATE trigger for auto-send on approval
- stale_sends view for 7-day non-responder detection
- Multi-tenant indexing on client_id

**Dependencies**: Resend API key (from Track C) for the pg_net trigger

## Track C: Resend Setup + Template + Domain

**Owner**: Agent C (Email)
**Step**: 5
**Deliverables**:
- Sending domain verified (SPF + DKIM via Cloudflare DNS)
- CAN-SPAM compliant base template with EU AI Act Art 50 disclosure
- 4 category templates seeded in Obsidian vault
- Resend API key generated

**Dependencies**: Cloudflare DNS access (from Track A setup)

## Track D: Obsidian Vault + 25-Agent Council + Template Seeding

**Owner**: Agent D (Council)
**Step**: 6
**Deliverables**:
- Full vault structure (agents/, config/, templates/, runs/, refinement/)
- client-identity.yaml and brand-voice-register.md
- 25 agent prompt modules (A1-E5)
- Manual entry prompt for non-QR connections
- Template seeding with 4 category follow-ups

**Dependencies**: None (fully independent)

## Convergence: Step 7

All tracks merge for the end-to-end pipeline test:
1. QR scan hits Track A landing page
2. Form submits to Track B Supabase
3. Claude Council (Track D) drafts follow-up
4. Approval triggers pg_net (Track B) to call Resend (Track C)
5. 9-point compliance and health checklist validates everything

## Dependency Graph

```mermaid
flowchart LR
    A[Track A: Landing Page] -->|provides URL| CONVERGE[Step 7: E2E Test]
    B[Track B: Supabase] -->|provides DB| CONVERGE
    C[Track C: Resend] -->|provides API key| B
    C -->|provides templates| D[Track D: Council]
    D -->|provides prompts| CONVERGE
    A -->|provides DNS| C
```

Why document dependencies between tracks?

Track B needs the Resend API key from Track C to configure the pg_net trigger. Track A needs the Supabase URL from Track B for the form submission. By mapping these upfront, you know exactly which pieces to pass between agents when they converge.

  • Save execution-plan.md.
  • Switch to Reading view. You should see the dependency graph rendered as a left-to-right flowchart showing how the 4 tracks connect.

Not seeing the dependency graph render?

Check that the code fence uses exactly ```mermaid (three backticks, no indent). If you are still in Editing view, click the book icon to switch to Reading view where Mermaid renders.

My execution plan diagram is not rendering in Obsidian.

  • Verify your vault structure by checking the Obsidian sidebar. You should see:
Council/
├── adrs/
│   ├── adr-001-cloudflare-pages.md
│   ├── adr-002-supabase.md
│   ├── adr-003-claude-only.md
│   ├── adr-004-static-html.md
│   ├── adr-005-obsidian-vault.md
│   └── adr-006-turnstile.md
├── architecture.md
└── execution-plan.md

What have you built?

You now have a complete pre-build package: 6 decisions documented with rationale, a visual system diagram showing every data path, and a parallel execution plan that maps exactly which steps can run simultaneously. Any developer can pick this up and understand the system without talking to you.

Your blueprint is locked in. Next up, you will build the mobile-first QR landing page that conference attendees scan to connect with Marcus.

Build the Mobile-First QR Landing Page

Marcus needs a page that loads instantly on any phone at any event. The moment someone scans his QR code, they land on a fast, mobile-first page where they can drop their details in seconds.

In this step, you will build that landing page as a single index.html file. No build step, no framework, no dependencies that rot. Just static HTML with Tailwind CSS for styling, Cloudflare Turnstile for bot protection, and a direct fetch() call to the Supabase REST API.

In this step, get ready to:
  • Create the HTML structure with a hero section, contact links, and GDPR consent form.
  • Integrate Cloudflare Turnstile bot protection in managed mode.
  • Wire the form to Supabase with UTM attribution from the QR code.
Create the HTML structure

This single HTML file is the entire landing page. It includes the head section with CDN links, a hero with Marcus's identity, contact quick-links, and the consent form.

  • Create a new file called index.html in your project root folder by running:
touch index.html
  • Open index.html in your editor and paste the following HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Connect with Marcus</title>

  <!-- Open Graph meta tags for social sharing -->
  <meta property="og:title" content="Connect with Marcus">
  <meta property="og:description" content="Drop your details and let's continue the conversation.">
  <meta property="og:type" content="website">

  <!-- Tailwind CSS Play CDN - zero build step -->
  <script src="https://cdn.tailwindcss.com"></script>

  <!-- Cloudflare Turnstile bot protection -->
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body class="bg-gray-900 text-white min-h-screen flex items-center justify-center p-4">
  <main class="w-full max-w-md mx-auto">

    <!-- Hero: Marcus's identity and StoryBrand one-liner -->
    <section class="text-center mb-8" aria-label="About Marcus">
      <img
        src="marcus-photo.jpg"
        alt="Marcus - Technology Leader"
        class="w-24 h-24 rounded-full mx-auto mb-4 object-cover"
      >
      <h1 class="text-2xl font-bold mb-2">Marcus</h1>
      <p class="text-gray-300">Helping leaders build systems that scale without burning out.</p>
    </section>

    <!-- Contact quick-links: LinkedIn prefill, mailto, tel -->
    <nav class="flex justify-center gap-4 mb-8" aria-label="Contact links">
      <a
        href="https://linkedin.com/in/marcus"
        target="_blank"
        rel="noopener"
        class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-blue-400"
        aria-label="Connect on LinkedIn"
      >LinkedIn</a>
      <a
        href="mailto:marcus@example.com?subject=Great%20meeting%20you"
        class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-gray-400"
        aria-label="Send email"
      >Email</a>
      <a
        href="tel:+1234567890"
        class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-gray-400"
        aria-label="Call phone"
      >Phone</a>
    </nav>

  </main>
</body>
</html>

What does this code do?

The <head> loads Tailwind CSS from the Play CDN (zero build step) and the Turnstile script. Open Graph tags make the page look good when shared on social media.

The hero section shows Marcus's photo and one-liner so visitors immediately know who they connected with. The <nav> provides LinkedIn (prefilled connect URL), email (mailto with subject line), and phone (tel: link) for instant contact options.

  • Now add the GDPR consent form below the </nav> closing tag but still inside <main>. Paste this code:
    <!-- GDPR consent form -->
    <form id="contact-form" class="space-y-4" novalidate>
      <div>
        <label for="name" class="block text-sm font-medium text-gray-300 mb-1">
          Name <span class="text-red-400">*</span>
        </label>
        <input
          type="text" id="name" name="name" required
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
      </div>

      <div>
        <label for="email" class="block text-sm font-medium text-gray-300 mb-1">
          Email <span class="text-red-400">*</span>
        </label>
        <input
          type="email" id="email" name="email" required
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
      </div>

      <div>
        <label for="linkedin" class="block text-sm font-medium text-gray-300 mb-1">
          LinkedIn handle (optional)
        </label>
        <input
          type="text" id="linkedin" name="linkedin"
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
        >
      </div>

      <div>
        <label for="note" class="block text-sm font-medium text-gray-300 mb-1">
          What should we discuss next? (optional)
        </label>
        <textarea
          id="note" name="note" rows="3"
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
        ></textarea>
      </div>

      <!-- GDPR consent checkbox -->
      <div class="flex items-start gap-2">
        <input
          type="checkbox" id="consent" name="consent" required
          class="mt-1 w-4 h-4 rounded border-gray-600 bg-gray-800 focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
        <label for="consent" class="text-sm text-gray-300">
          I consent to Marcus storing my information to follow up on our conversation. I can request deletion at any time.
        </label>
      </div>

      <!-- Cloudflare Turnstile widget (managed mode) -->
      <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITEKEY" data-callback="onTurnstileSuccess" data-theme="dark"></div>

      <!-- Submit disabled until Turnstile verifies -->
      <button
        type="submit" id="submit-btn" disabled
        class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400"
      >
        Let's Connect
      </button>
    </form>

    <!-- Success message (hidden until submission completes) -->
    <div id="success-message" class="hidden text-center mt-6">
      <p class="text-green-400 text-lg font-medium">Thanks! Marcus will follow up soon.</p>
    </div>

What does this form do?

The form collects only what Marcus needs: name (required), email (required), LinkedIn handle (optional), and a note field (optional). This follows GDPR data minimization principles.

The consent checkbox with a timestamp satisfies GDPR lawful basis (Art 6). The Turnstile cf-turnstile div renders the bot-protection widget. The submit button starts disabled and only enables after Turnstile verifies the visitor is human.

  • Save index.html.
  • Open the file in your browser. You should see a dark page with Marcus's placeholder photo area, three contact buttons, form fields, and a grayed-out submit button.

Page looks unstyled?

Make sure you have an internet connection. The Tailwind Play CDN loads over the network. If you see raw HTML without styling, check that the <script src="https://cdn.tailwindcss.com"> tag is present in your <head>.

help me debug my unstyled landing page

Integrate Cloudflare Turnstile

Turnstile is Cloudflare's free CAPTCHA alternative. In managed mode, it auto-decides the challenge level based on browser signals. Most real conference attendees pass invisibly. Bots get blocked.

The Turnstile script and widget div are already in your HTML from the previous chunk. Now you need the JavaScript callback that enables the submit button when Turnstile verifies the visitor.

  • Add the following <script> block just before the closing </body> tag:
  <script>
    // Enable submit button when Turnstile verifies the visitor is human
    function onTurnstileSuccess(token) {
      document.getElementById('submit-btn').disabled = false;
    }

    // Capture UTM parameters from the QR code URL
    function getUTMParams() {
      const params = new URLSearchParams(window.location.search);
      return {
        utm_source: params.get('utm_source') || '',
        utm_medium: params.get('utm_medium') || '',
        utm_campaign: params.get('utm_campaign') || ''
      };
    }

    // Handle form submission to Supabase REST API
    document.getElementById('contact-form').addEventListener('submit', async (e) => {
      e.preventDefault();

      const form = e.target;
      const submitBtn = document.getElementById('submit-btn');
      submitBtn.disabled = true;
      submitBtn.textContent = 'Sending...';

      // Collect form data + UTM attribution
      const utmParams = getUTMParams();
      const payload = {
        name: form.name.value,
        email: form.email.value,
        linkedin: form.linkedin.value || null,
        note: form.note.value || null,
        consent: true,
        consent_timestamp: new Date().toISOString(),
        utm_source: utmParams.utm_source,
        utm_medium: utmParams.utm_medium,
        utm_campaign: utmParams.utm_campaign
      };

      try {
        // POST to Supabase REST API (visitors table)
        const response = await fetch('YOUR_SUPABASE_URL/rest/v1/visitors', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'apikey': 'YOUR_SUPABASE_ANON_KEY',
            'Authorization': 'Bearer YOUR_SUPABASE_ANON_KEY',
            'Prefer': 'return=minimal'
          },
          body: JSON.stringify(payload)
        });

        if (response.ok) {
          // Hide form and show success message
          form.classList.add('hidden');
          document.getElementById('success-message').classList.remove('hidden');
        } else {
          throw new Error('Submission failed');
        }
      } catch (error) {
        // Re-enable button on failure
        submitBtn.disabled = false;
        submitBtn.textContent = "Let's Connect";
        alert('Something went wrong. Please try again.');
      }
    });
  </script>

How does the Turnstile flow work?

When the page loads, Turnstile renders inside the cf-turnstile div and runs its challenge silently. If the visitor passes, Turnstile calls onTurnstileSuccess (set via the data-callback attribute). That function enables the submit button.

The sitekey placeholder YOUR_TURNSTILE_SITEKEY will be replaced in Step 3 after you create the Turnstile widget in the Cloudflare Dashboard. For now, the widget will not render. That is expected.

💡 Why no server-side Turnstile validation?

Full Turnstile integration requires server-side token validation via the Siteverify API. For the 90-minute foundation, client-side widget protection plus Supabase RLS (INSERT-only for anonymous users) provides sufficient protection for a low-stakes contact form. Server-side validation is documented as a Day 7 hardening step.

Wire form to Supabase with UTM attribution

The JavaScript you just added handles both the UTM capture and the Supabase submission. The QR code encodes UTM parameters in the URL, so every scan is attributed to the specific event where Marcus met the contact.

How does UTM attribution work?

When Marcus generates a QR code for an event, the URL includes parameters like ?utm_source=qr&utm_medium=event&utm_campaign=ai-builders-conf. The getUTMParams() function reads these from the URL and includes them in the payload sent to Supabase.

This means Marcus can see exactly which events generate the most connections. No analytics SDK needed.

💡 What about WCAG 2.2 AA compliance?

  • All form inputs use semantic <label> elements with for attributes for screen reader association.
  • The aria-required="true" and aria-label attributes provide context for assistive technology.
  • Focus rings (focus:ring-2) ensure keyboard navigation is visible.
  • The white-on-dark color scheme meets the 4.5:1 contrast ratio requirement.
  • Save index.html.
  • Refresh the page in your browser. The form fields, consent checkbox, and disabled submit button should all be visible. The Turnstile widget will not render yet (the sitekey is a placeholder). You will connect it in Step 3.

Submit button never enables?

This is expected until you replace YOUR_TURNSTILE_SITEKEY with a real sitekey from the Cloudflare Dashboard. You will do this in Step 3. For now, the button stays grayed out.

If you want to test the form submission logic before Step 3, temporarily remove the disabled attribute from the submit button in your HTML. Remember to add it back before deploying.

help me test my landing page form without a Turnstile sitekey

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and can see the form rendering in your browser.

ⓧ I'd like to double check the full code

Here is the complete index.html file. Compare yours against this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Connect with Marcus</title>

  <!-- Open Graph meta tags for social sharing -->
  <meta property="og:title" content="Connect with Marcus">
  <meta property="og:description" content="Drop your details and let's continue the conversation.">
  <meta property="og:type" content="website">

  <!-- Tailwind CSS Play CDN - zero build step -->
  <script src="https://cdn.tailwindcss.com"></script>

  <!-- Cloudflare Turnstile bot protection -->
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body class="bg-gray-900 text-white min-h-screen flex items-center justify-center p-4">
  <main class="w-full max-w-md mx-auto">

    <!-- Hero: Marcus's identity and StoryBrand one-liner -->
    <section class="text-center mb-8" aria-label="About Marcus">
      <img
        src="marcus-photo.jpg"
        alt="Marcus - Technology Leader"
        class="w-24 h-24 rounded-full mx-auto mb-4 object-cover"
      >
      <h1 class="text-2xl font-bold mb-2">Marcus</h1>
      <p class="text-gray-300">Helping leaders build systems that scale without burning out.</p>
    </section>

    <!-- Contact quick-links: LinkedIn prefill, mailto, tel -->
    <nav class="flex justify-center gap-4 mb-8" aria-label="Contact links">
      <a
        href="https://linkedin.com/in/marcus"
        target="_blank"
        rel="noopener"
        class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-blue-400"
        aria-label="Connect on LinkedIn"
      >LinkedIn</a>
      <a
        href="mailto:marcus@example.com?subject=Great%20meeting%20you"
        class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-gray-400"
        aria-label="Send email"
      >Email</a>
      <a
        href="tel:+1234567890"
        class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-gray-400"
        aria-label="Call phone"
      >Phone</a>
    </nav>

    <!-- GDPR consent form -->
    <form id="contact-form" class="space-y-4" novalidate>
      <div>
        <label for="name" class="block text-sm font-medium text-gray-300 mb-1">
          Name <span class="text-red-400">*</span>
        </label>
        <input
          type="text" id="name" name="name" required
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
      </div>

      <div>
        <label for="email" class="block text-sm font-medium text-gray-300 mb-1">
          Email <span class="text-red-400">*</span>
        </label>
        <input
          type="email" id="email" name="email" required
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
      </div>

      <div>
        <label for="linkedin" class="block text-sm font-medium text-gray-300 mb-1">
          LinkedIn handle (optional)
        </label>
        <input
          type="text" id="linkedin" name="linkedin"
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
        >
      </div>

      <div>
        <label for="note" class="block text-sm font-medium text-gray-300 mb-1">
          What should we discuss next? (optional)
        </label>
        <textarea
          id="note" name="note" rows="3"
          class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
        ></textarea>
      </div>

      <!-- GDPR consent checkbox -->
      <div class="flex items-start gap-2">
        <input
          type="checkbox" id="consent" name="consent" required
          class="mt-1 w-4 h-4 rounded border-gray-600 bg-gray-800 focus:ring-2 focus:ring-blue-400"
          aria-required="true"
        >
        <label for="consent" class="text-sm text-gray-300">
          I consent to Marcus storing my information to follow up on our conversation. I can request deletion at any time.
        </label>
      </div>

      <!-- Cloudflare Turnstile widget (managed mode) -->
      <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITEKEY" data-callback="onTurnstileSuccess" data-theme="dark"></div>

      <!-- Submit disabled until Turnstile verifies -->
      <button
        type="submit" id="submit-btn" disabled
        class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400"
      >
        Let's Connect
      </button>
    </form>

    <!-- Success message (hidden until submission completes) -->
    <div id="success-message" class="hidden text-center mt-6">
      <p class="text-green-400 text-lg font-medium">Thanks! Marcus will follow up soon.</p>
    </div>

  </main>

  <script>
    // Enable submit button when Turnstile verifies the visitor is human
    function onTurnstileSuccess(token) {
      document.getElementById('submit-btn').disabled = false;
    }

    // Capture UTM parameters from the QR code URL
    function getUTMParams() {
      const params = new URLSearchParams(window.location.search);
      return {
        utm_source: params.get('utm_source') || '',
        utm_medium: params.get('utm_medium') || '',
        utm_campaign: params.get('utm_campaign') || ''
      };
    }

    // Handle form submission to Supabase REST API
    document.getElementById('contact-form').addEventListener('submit', async (e) => {
      e.preventDefault();

      const form = e.target;
      const submitBtn = document.getElementById('submit-btn');
      submitBtn.disabled = true;
      submitBtn.textContent = 'Sending...';

      // Collect form data + UTM attribution
      const utmParams = getUTMParams();
      const payload = {
        name: form.name.value,
        email: form.email.value,
        linkedin: form.linkedin.value || null,
        note: form.note.value || null,
        consent: true,
        consent_timestamp: new Date().toISOString(),
        utm_source: utmParams.utm_source,
        utm_medium: utmParams.utm_medium,
        utm_campaign: utmParams.utm_campaign
      };

      try {
        // POST to Supabase REST API (visitors table)
        const response = await fetch('YOUR_SUPABASE_URL/rest/v1/visitors', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'apikey': 'YOUR_SUPABASE_ANON_KEY',
            'Authorization': 'Bearer YOUR_SUPABASE_ANON_KEY',
            'Prefer': 'return=minimal'
          },
          body: JSON.stringify(payload)
        });

        if (response.ok) {
          // Hide form and show success message
          form.classList.add('hidden');
          document.getElementById('success-message').classList.remove('hidden');
        } else {
          throw new Error('Submission failed');
        }
      } catch (error) {
        // Re-enable button on failure
        submitBtn.disabled = false;
        submitBtn.textContent = "Let's Connect";
        alert('Something went wrong. Please try again.');
      }
    });
  </script>
</body>
</html>

Your landing page is built. Next up, you will deploy it to Cloudflare Pages and connect the Turnstile widget so bot protection goes live.

Deploy to Cloudflare Pages and Set Up Infrastructure

You have a landing page ready to capture connections. Now it needs to go live on the internet so people can actually scan Marcus's QR and reach it.

In this step, you will push your code to GitHub, deploy it to Cloudflare Pages for global CDN delivery, activate Cloudflare Turnstile bot protection, and set up a keep-alive Worker to prevent your Supabase free tier from pausing.

In this step, get ready to:
  • Push your project to GitHub and deploy it to Cloudflare Pages.
  • Create a Turnstile widget and connect it to your live landing page.
  • Deploy a keep-alive Worker with a cron trigger and enable Web Analytics.
Push to GitHub and deploy to Cloudflare Pages

Cloudflare Pages needs your code in a GitHub repository so it can automatically redeploy every time you push a change.

  • Open your browser and go to https://github.com/new to create a new repository.
  • Name it something meaningful like connection-engine and leave it public.
  • Do not add a README or .gitignore during creation. You will push your existing code directly.
  • Click Create repository.
  • Initialize your local project as a Git repo and push it by running these commands from your project folder:
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/your-username/connection-engine.git
git push -u origin main

What do these commands do?

  • `git init` creates a new Git repository in your folder.
  • `git add .` stages all your files for the first commit.
  • `git push -u origin main` sends your code to GitHub so Cloudflare Pages can pick it up.

Now connect Cloudflare Pages to your GitHub repo.

  • Log in to the Cloudflare dashboard.
  • Select Workers & Pages in the left sidebar.
  • Click Create.
  • Select the Pages tab.
  • Select Connect to Git.
  • Sign in to GitHub when prompted and authorize Cloudflare.
  • Select your connection-engine repository, then click Begin setup.
  • Configure the build settings as follows:
  • Set Production branch to main.
  • Set Build command to exit 0 (your site is static HTML with no build step).
  • Leave Build output directory as / (the root of your repo).
  • Click Save and Deploy.
  • Wait for the deployment to finish. You will see a success message with your live URL.
  • Open your Pages URL (e.g., https://connection-engine.pages.dev) in your browser.
  • Confirm you see Marcus's landing page with the contact form. HTTPS is active automatically.

Why Cloudflare Pages over Vercel?

Vercel's Hobby plan prohibits commercial use. Cloudflare Pages Free allows commercial hosting with unlimited bandwidth and unlimited requests. You also get Workers, Turnstile, Analytics, and DNS all under one roof at no cost.

🙋‍♀️ Seeing a 404 error?

Make sure your repository has a top-level index.html file. Cloudflare Pages serves index.html as the root page.

Also check that the Build output directory is set to / and not something like dist or build.

Help me debug my Cloudflare Pages deployment

Create the Turnstile widget in Cloudflare

Your landing page already has the Turnstile HTML placeholder. Now you need to create the actual widget in Cloudflare to get a real sitekey.

  • In the Cloudflare dashboard, go to the Turnstile page (in the left sidebar).
  • Click Add widget.
  • Enter a Widget name like Connection Engine Landing.
  • Under Hostname management, enter your Pages domain (e.g., connection-engine.pages.dev).
  • Set Widget mode to Managed.
  • Click Create.
  • Copy your sitekey and secret key. Store the secret key somewhere safe (a password manager or secure note). You will need it later for server-side validation hardening.

What is Managed mode?

Managed mode lets Cloudflare auto-decide the challenge level based on the visitor's risk signals. Most real conference attendees will pass invisibly. Only suspicious traffic sees a checkbox.

  • Open your index.html file and find the cf-turnstile div.
  • Replace the placeholder sitekey value in the data-sitekey attribute with your real sitekey from the Cloudflare dashboard.
  • Save index.html.
  • Push the update to GitHub by running:
git add index.html
git commit -m "Add real Turnstile sitekey"
git push
  • Cloudflare Pages will automatically redeploy. Wait about 30 seconds, then refresh your Pages URL.
  • You should see the Turnstile widget area in the form (it may be invisible for low-risk visitors in Managed mode, which is expected).

Turnstile not appearing?

In Managed mode, the widget is often invisible to real visitors. Open your browser's DevTools Network tab and look for requests to challenges.cloudflare.com to confirm the script is loading.

Also confirm that the hostname you entered in the Turnstile widget matches your actual Pages domain exactly.

Help me verify my Turnstile widget is working

Deploy the keep-alive Worker and enable analytics

Supabase's free tier pauses your database after 1 week of inactivity. A simple Worker that pings Supabase once a day on a schedule prevents this.

  • In the Cloudflare dashboard, go to Workers & Pages.
  • Click Create.
  • Select Create Worker.
  • Name it connection-engine-keepalive.
  • Click Deploy to create the Worker with the default code.
  • Once deployed, click Edit code to open the Worker editor.
  • Replace all existing code with the keep-alive handler:
// Keep-alive Worker: pings Supabase daily to prevent free-tier pause
export default {
  async scheduled(controller, env, ctx) {
    // Ping the Supabase REST endpoint to keep the database active
    const response = await fetch(env.SUPABASE_URL + '/rest/v1/', {
      headers: {
        'apikey': env.SUPABASE_ANON_KEY,
        'Authorization': 'Bearer ' + env.SUPABASE_ANON_KEY
      }
    });
    console.log('Keep-alive ping status:', response.status);
  },

  async fetch(request, env, ctx) {
    // Health check endpoint
    return new Response('Connection Engine keep-alive Worker is running.', {
      status: 200
    });
  }
};

What does this code do?

  • The scheduled() handler runs on a cron schedule. It sends a simple GET request to your Supabase REST API to register activity.
  • The fetch() handler provides a basic health check endpoint if you visit the Worker URL directly.
  • Environment variables SUPABASE_URL and SUPABASE_ANON_KEY will be configured as Worker secrets in the next steps.
  • Click Deploy in the Worker editor to save your code.

Now add the cron trigger so this Worker runs daily at 8 AM UTC.

  • From your Worker page, go to Settings in the top menu.
  • Select Triggers.
  • Under Cron Triggers, click Add Cron Trigger.
  • Enter the cron expression 0 8 * * * (runs once daily at 8:00 UTC).
  • Click Add Trigger.

What does 0 8 * * * mean?

This is a standard cron expression: minute 0, hour 8, every day, every month, every weekday. It runs the Worker once daily at 8:00 AM UTC.

  • Still in Settings, scroll to Variables and Secrets.
  • Add two environment variables (you will get the actual Supabase values in Step 4):
  • SUPABASE_URL - your Supabase project URL (e.g., https://xxxx.supabase.co).
  • SUPABASE_ANON_KEY - your Supabase anon/public key.
  • Click Encrypt on the anon key to store it as a secret.
  • Click Deploy to apply the new configuration.

Fill these in after Step 4

If you have not set up Supabase yet, you can leave placeholder values for now. Come back and update them after you create your Supabase project in Step 4.

Finally, enable Cloudflare Web Analytics on your Pages project for conversion tracking.

  • Return to Workers & Pages in the Cloudflare dashboard.
  • Select your Pages project (connection-engine).
  • Go to Metrics.
  • Click Enable under Web Analytics.

Cloudflare will automatically inject the analytics JavaScript snippet on your next deployment. No cookies, fully privacy-first.

What does Web Analytics track?

Web Analytics tracks page views and visitor counts using the browser's Performance API. Because your QR codes use UTM parameters, you will be able to see which events drive the most scans once you start generating QR codes in Step 7.

  • Trigger a redeployment to activate analytics by running:
git commit --allow-empty -m "Trigger redeploy for Web Analytics"
git push
  • After the deployment completes, go to Web Analytics in the left sidebar of the Cloudflare dashboard.
  • You should see your Pages project listed. Data will appear once real visitors start loading the page.

Web Analytics not showing up?

The automatic snippet injection requires valid HTML. Since your index.html already has proper <!DOCTYPE html>, <html>, <head>, and <body> tags, this should work automatically.

If you don't see it after a few minutes, check that the deployment succeeded on your Pages project overview page.

Help me troubleshoot Web Analytics injection

Your infrastructure backbone is live. You have a globally-distributed landing page, bot protection, a keep-alive system, and privacy-first analytics. Next up, you will provision the Supabase CRM that stores every connection Marcus makes.

Provision Supabase CRM with Auto-Send Trigger

Your landing page is live and capturing visitors. But right now, those form submissions disappear into a black hole.

In this step, you will build the database layer that stores every connection, tracks every follow-up, and automatically fires emails the instant Marcus approves them. No manual API calls, no middleman. One tap on the phone, the database handles the rest.

In this step, get ready to:
  • Create a multi-tenant CRM schema with indexed tables for visitors, events, sends, and responses.
  • Enable Row Level Security policies that lock down data access by role and client.
  • Wire a database trigger that auto-sends emails via Resend the moment Marcus approves a draft.
Create the database schema

Every connection Marcus makes needs a permanent home. The schema stores visitors (who scanned), events (where they met), sends (the follow-up drafts and their approval status), and responses (who replied and how they felt about it).

Every table includes a client_id column with an index. This makes the system multi-tenant from Day 1. Marcus is the only client today, but the architecture supports 100 clients without a single migration.

  • Return to your Supabase project dashboard in the browser.
  • Click SQL Editor in the left sidebar.
  • Click New query in the top left.
  • Paste the following SQL to create all four tables:
-- Enable pg_net for async HTTP requests from triggers
create extension if not exists pg_net;

-- Visitors: people who scanned Marcus's QR code
create table public.visitors (
  id uuid primary key default gen_random_uuid(),
  client_id uuid not null,
  name text not null,
  email text not null,
  linkedin_handle text,
  note text,
  utm_source text,
  utm_medium text,
  utm_campaign text,
  consent_timestamp timestamptz not null default now(),
  created_at timestamptz not null default now()
);

-- Index for multi-tenant queries
create index idx_visitors_client_id on public.visitors (client_id);

-- Events: QR performance tracking per conference/meetup
create table public.events (
  id uuid primary key default gen_random_uuid(),
  client_id uuid not null,
  event_name text not null,
  qr_url text,
  created_at timestamptz not null default now()
);

create index idx_events_client_id on public.events (client_id);

-- Sends: follow-up drafts and approval workflow
create table public.sends (
  id uuid primary key default gen_random_uuid(),
  client_id uuid not null,
  visitor_id uuid references public.visitors (id),
  draft text not null,
  subject text not null,
  recipient_email text not null,
  status text not null default 'pending'
    check (status in ('pending', 'approved', 'sent', 'rejected')),
  rejection_reason text,
  approved_at timestamptz,
  sent_at timestamptz,
  created_at timestamptz not null default now()
);

create index idx_sends_client_id on public.sends (client_id);

-- Responses: reply tracking with sentiment
create table public.responses (
  id uuid primary key default gen_random_uuid(),
  send_id uuid references public.sends (id),
  client_id uuid not null,
  replied_at timestamptz not null default now(),
  sentiment text,
  created_at timestamptz not null default now()
);

create index idx_responses_client_id on public.responses (client_id);
  • Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows).

What does this schema do?

Each table uses uuid primary keys generated automatically by Postgres. The client_id column on every table is what makes this multi-tenant. When you add a second client later, you just filter by their ID.

The sends table is the heart of the automation. Its status column tracks the lifecycle: pending (drafted, awaiting approval), approved (Marcus tapped approve), sent (email delivered), or rejected (Marcus declined with a reason that feeds improvement).

  • Verify the tables exist by clicking Table Editor in the left sidebar.
  • You should see visitors, events, sends, and responses listed under the public schema.

Tables not showing up?

Check the SQL Editor output for errors. A common issue is a typo in column names or a missing comma between column definitions. Make sure you ran the entire SQL block, not just a portion of it.

help me debug my Supabase schema creation SQL

Enable RLS and create policies

Row Level Security controls who can read and write each row. Without it, anyone with your Supabase anon key could read every visitor's email address. With it, you define exact rules: anonymous visitors can only insert (not read), and authenticated users can only see their own client's data.

  • Open a new query in the SQL Editor and paste the following RLS policies:
-- Enable RLS on all tables
alter table public.visitors enable row level security;
alter table public.events enable row level security;
alter table public.sends enable row level security;
alter table public.responses enable row level security;

-- Visitors: anon can INSERT only (form submissions)
create policy "anon_insert_visitors"
  on public.visitors
  for insert
  to anon
  with check (true);

-- Visitors: authenticated users see their own client's records
create policy "auth_select_visitors"
  on public.visitors
  for select
  to authenticated
  using (client_id = auth.uid());

-- Events: authenticated users see their own events
create policy "auth_select_events"
  on public.events
  for select
  to authenticated
  using (client_id = auth.uid());

-- Sends: authenticated users see and update their own sends
create policy "auth_select_sends"
  on public.sends
  for select
  to authenticated
  using (client_id = auth.uid());

create policy "auth_update_sends"
  on public.sends
  for update
  to authenticated
  using (client_id = auth.uid())
  with check (client_id = auth.uid());

-- Responses: authenticated users see their own responses
create policy "auth_select_responses"
  on public.responses
  for select
  to authenticated
  using (client_id = auth.uid());

-- Service role bypasses RLS by default (full access)
  • Click Run to execute the policies.

What do these policies enforce?

The anon role (used by your landing page's fetch POST) can only INSERT into visitors. It cannot SELECT, UPDATE, or DELETE anything. This means even if someone inspects your page source and finds the Supabase URL, they cannot read other visitors' data.

The authenticated role (used by Marcus's dashboard) can SELECT and UPDATE only rows matching their own client_id. The service role automatically bypasses RLS, giving your backend and triggers full access.

  • Test the anon policy by running this query in the SQL Editor:
-- Test: anon cannot read visitors
set role anon;
select * from public.visitors;
-- Should return 0 rows (policy blocks reads)
  • Click Run. You should see an empty result set with no error, confirming that the anon role is blocked from reading.
  • Reset your role back to the default by running:
-- Reset to default role
reset role;

Seeing a permission denied error?

If you see a permission error instead of an empty result, RLS is working correctly. The exact behaviour depends on your Supabase project version. The important thing is that no visitor rows are returned to the anon role.

help me verify my Supabase RLS policies are working correctly

Create the pg_net auto-send trigger and second-touch logic

This is where the magic happens. When Marcus taps Approve on his phone, the dashboard changes the send's status from pending to approved. A BEFORE UPDATE trigger intercepts that change, calls the Resend API via pg_net, and sets the status to sent in the same transaction. The email fires automatically.

  • First, store your Resend API key securely by opening a new query in the SQL Editor and running:
-- Store the Resend API key as a database setting
-- Replace the placeholder with your actual Resend API key
ALTER DATABASE postgres SET app.resend_api_key = 'your-resend-api-key-here';

Why store the key this way?

The ALTER DATABASE ... SET command stores the key as a Postgres configuration parameter. Your trigger retrieves it at runtime with current_setting('app.resend_api_key', true). This keeps the secret out of your code and accessible only to database functions.

  • Click Run to store the key.
  • Now create the trigger function and attach it to the sends table by pasting and running:
-- Trigger function: auto-send email via Resend when status changes to approved
create or replace function public.handle_send_approval()
returns trigger as $$
declare
  resend_key text;
  request_body jsonb;
begin
  -- Only fire when status changes from 'pending' to 'approved'
  if OLD.status = 'pending' and NEW.status = 'approved' then
    -- Retrieve the Resend API key from database settings
    resend_key := current_setting('app.resend_api_key', true);

    -- Build the Resend API request body
    request_body := jsonb_build_object(
      'from', 'Marcus <notifications@yourdomain.com>',
      'to', array[NEW.recipient_email],
      'subject', NEW.subject,
      'html', NEW.draft
    );

    -- Fire async HTTP POST to Resend API
    perform net.http_post(
      url := 'https://api.resend.com/emails',
      body := request_body,
      headers := jsonb_build_object(
        'Content-Type', 'application/json',
        'Authorization', 'Bearer ' || resend_key
      )
    );

    -- Update status to 'sent' and record timestamps in same transaction
    NEW.status := 'sent';
    NEW.sent_at := now();
    NEW.approved_at := now();
  end if;

  return NEW;
end;
$$ language plpgsql security definer;

-- Attach the trigger to the sends table
create trigger trigger_send_on_approval
  before update on public.sends
  for each row
  execute function public.handle_send_approval();

How does this trigger work?

  • The trigger runs BEFORE UPDATE so it can modify the NEW row before Postgres writes it. This means the status becomes sent in a single atomic operation.
  • The net.http_post() call is async. It queues the HTTP request and delivers it after the transaction commits. This means the UPDATE does not block waiting for Resend's response.
  • The security definer clause runs the function as the owner (postgres), bypassing RLS so the trigger can read the API key and modify the row regardless of who initiated the UPDATE.
  • Click Run to create the trigger.
  • Finally, create the stale_sends view for second-touch automation by running:
-- View: identifies sent emails with no response after 7 days
-- Used by the C4 Second-Touch Drafter agent
create or replace view public.stale_sends as
select
  s.id as send_id,
  s.client_id,
  s.visitor_id,
  s.recipient_email,
  s.subject,
  s.sent_at,
  v.name as visitor_name,
  v.note as visitor_note
from public.sends s
join public.visitors v on v.id = s.visitor_id
where s.status = 'sent'
  and s.sent_at < now() - interval '7 days'
  and not exists (
    select 1 from public.responses r
    where r.send_id = s.id
  );
  • Click Run to create the view.

What is the stale_sends view for?

If someone does not reply within 7 days, this view flags them. The E5 Weekly Refinement agent queries this view and passes stale contacts to the C4 Second-Touch Drafter, which generates a value-add follow-up. Not nagging. A genuine reason to reconnect.

  • Verify the trigger exists by navigating to Database in the left sidebar, then Triggers.
  • You should see trigger_send_on_approval listed under the sends table.

Trigger not appearing?

Make sure you ran both the function creation (create or replace function) and the trigger attachment (create trigger) together. If you ran them separately and the function had an error, the trigger creation would fail silently.

Also confirm pg_net is enabled. Go to Database > Extensions and search for pg_net. It should show as enabled.

help me debug my pg_net trigger in Supabase

  • Note your Supabase project URL and anon key. Find these by clicking Settings > API in the left sidebar.
  • Copy the Project URL and anon public key. You will need these for your landing page and Worker.

Your CRM is now fully wired. Every form submission lands in a secure, multi-tenant database. Every approved follow-up fires instantly without a single manual API call. Next up, you will configure Resend to actually deliver those emails with full legal compliance.

Configure Resend Email Delivery

Your Supabase CRM is wired up with a pg_net trigger that fires the moment Marcus approves a follow-up. The trigger calls the Resend API to deliver the email instantly.

For Resend to actually send on Marcus's behalf, it needs proof that he owns his domain. In this step, you will verify the sending domain with DNS records, create a legally compliant base template, and seed four category-specific templates that the Connection Council uses to draft follow-ups.

In this step, get ready to:
  • Verify your sending domain with SPF and DKIM records in Cloudflare DNS.
  • Create a CAN-SPAM-compliant base email template with EU AI Act Art 50 disclosure.
  • Seed four category follow-up templates in Obsidian for different event types.
Verify your sending domain in Resend

Resend needs DNS records on Marcus's domain to prove he authorized it to send emails. You will add an SPF TXT record and a DKIM TXT record through Cloudflare DNS.

  • Open your Resend dashboard at resend.com.
  • In the left sidebar, click Domains.
  • Click Add Domain.
  • Enter Marcus's domain name. Resend recommends using a subdomain like updates.yourdomain.com for reputation segmentation.
  • Resend displays the DNS records you need to add. Keep this page open.

What are SPF and DKIM?

SPF (Sender Policy Framework) is a TXT record listing the IP addresses authorized to send email on behalf of your domain. DKIM (DomainKeys Identified Mail) is a TXT record containing a public key used to verify email authenticity.

Together, they tell inbox providers that Resend is a legitimate sender for Marcus's domain. Without them, emails land in spam or get rejected entirely.

Now add the records in Cloudflare. Since your domain's DNS is already managed by Cloudflare (from the Pages deployment), you will add the records there.

  • Open a new tab and log in to the Cloudflare dashboard.
  • Select your domain, then click DNS in the left sidebar to go to the DNS Records page.
  • Click Add record.
  • Set the Type to TXT.
  • Type send in the Name field.
  • Copy the SPF TXT value from the Resend domain page and paste it into the Content field.
  • Leave TTL set to Auto.
  • Click Save.

Now add the DKIM record.

  • Click Add record again in Cloudflare DNS.
  • Set the Type to TXT.
  • Type resend._domainkey in the Name field.
  • Copy the DKIM TXT value from the Resend domain page and paste it into the Content field.
  • Leave TTL set to Auto.
  • Click Save.

Return to Resend and trigger verification.

  • Switch back to the Resend tab.
  • Click Verify DNS Records.
  • Wait 1-5 minutes. Refresh the page until the domain status shows Verified.

Domain not verifying after 5 minutes?

The most common cause is pasting the record values into the wrong field. Go back to Cloudflare DNS and confirm the SPF record has Name send and the DKIM record has Name resend._domainkey. Record values must match exactly what Resend generated.

DNS propagation can take up to 72 hours in rare cases, though it usually completes in minutes when both DNS and Resend are on Cloudflare. Use the Restart verification button in Resend to trigger a fresh check.

Help me troubleshoot my Resend domain verification with Cloudflare DNS

Create a CAN-SPAM-compliant base email template

Every email Marcus sends must comply with CAN-SPAM (physical address + unsubscribe) and include an EU AI Act Art 50 disclosure since the content is AI-drafted. You will create the base HTML template that every follow-up uses.

  • In Obsidian, create a new file at Council/templates/base-email.html with the following template:
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #1a1a1a;">

  <!-- Personalized greeting -->
  <p>Hi {{visitor_name}},</p>

  <!-- Main body: topic-specific follow-up -->
  <p>{{specific_topic}}</p>

  <!-- Value offer -->
  <p>{{value_offer}}</p>

  <!-- Sign-off -->
  <p>Best,<br>{{client_name}}</p>

  <!-- CAN-SPAM compliant footer -->
  <hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
  <footer style="font-size: 12px; color: #666;">
    <!-- Physical address (CAN-SPAM requirement) -->
    <p>{{physical_address}}</p>

    <!-- One-click unsubscribe (CAN-SPAM requirement) -->
    <p><a href="{{unsubscribe_link}}" style="color: #666;">Unsubscribe from future emails</a></p>

    <!-- EU AI Act Art 50 transparency disclosure -->
    <p>This message was drafted with AI assistance and personally reviewed by Marcus before sending.</p>
  </footer>

</body>
</html>

What makes this template compliant?

  • CAN-SPAM (15 U.S.C. 7701-7713) requires a physical mailing address and a one-click unsubscribe link in every commercial email.
  • EU AI Act Art 50 requires disclosure when AI-generated content interacts with natural persons. The footer satisfies this with a clear statement that the email was AI-drafted and human-reviewed.
  • Template variables ({{visitor_name}}, {{specific_topic}}, {{value_offer}}, {{client_name}}, {{physical_address}}, {{unsubscribe_link}}) get filled by the Connection Council agents at draft time and by Resend at send time.
  • Save Council/templates/base-email.html.
  • You should see the file in Obsidian's sidebar under Council > templates.

Why Art 50 even though Marcus reviews?

Art 50 includes a human review exception. However, the regulation takes effect 2 August 2026 and enforcement nuances are still developing. Including the disclosure by default gives Marcus a safety margin. It also builds trust with recipients who appreciate transparency.

Seed four category templates in Obsidian

Marcus attends four types of events: conferences, meetups, dinners, and ad-hoc encounters. Each needs a different tone and approach. You will create one follow-up template per category that the C1 agent uses as a starting point when drafting.

  • Create Council/templates/conference.md with the following content:
# Conference Follow-Up Template

## Tone
Professional warmth. Reference the shared experience of the talk/panel.

## Structure
1. Open with the specific talk or session you both attended
2. Reference their note about what they want to discuss
3. Offer a concrete resource (article, tool, intro) related to that topic
4. Close with a low-friction next step (async reply, not a calendar link)

## Template

Hi {{visitor_name}},

It was great connecting at [event_name]. [Reference to shared talk/session
and something specific they said or asked about.]

I thought you might find this useful: [specific resource related to
{{specific_topic}}]. It connects directly to what you mentioned about
[their note].

{{value_offer}}

Would love to hear your thoughts when you get a chance.

Best,
{{client_name}}

## Brand Voice Guardrails
- Adam Grant Give-and-Take register: lead with value, not asks
- No "let's hop on a call" (too aggressive for first follow-up)
- No generic "great to meet you" without specifics
- Keep under 150 words

What does this template do?

The template guides C1 (the Follow-Up Drafter agent) on tone, structure, and constraints for conference encounters. The shared-talk reference makes the email feel personal. The resource offer follows Marcus's Give-and-Take brand voice register. The guardrails prevent the agent from generating generic or overly aggressive follow-ups.

  • Save Council/templates/conference.md.
  • Create Council/templates/meetup.md with the following content:
# Meetup Follow-Up Template

## Tone
Casual and energetic. Meetups are informal, so match that vibe.

## Structure
1. Open with a reference to the meetup topic or venue
2. Mention their specific interest from the note field
3. Suggest a low-key coffee or virtual chat
4. Keep it short (meetup contacts expect brevity)

## Template

Hi {{visitor_name}},

Really enjoyed chatting at [meetup_name] last night. Your take on
{{specific_topic}} stuck with me.

{{value_offer}}

Would you be up for grabbing a coffee sometime this week? Happy to
do virtual if that's easier.

Cheers,
{{client_name}}

## Brand Voice Guardrails
- Casual but not sloppy (no slang, no all-lowercase)
- Coffee invite is the default CTA for meetups
- Reference something specific from their note
- Keep under 100 words
  • Save Council/templates/meetup.md.
  • Create Council/templates/dinner.md with the following content:
# Dinner Follow-Up Template

## Tone
Intimate and thoughtful. Dinners create deeper connections than
large events. Honor that with a more personal register.

## Structure
1. Open with something specific from the dinner conversation
2. Name the collaboration angle they mentioned
3. Propose a concrete next step toward that collaboration
4. Warm, unhurried sign-off

## Template

Hi {{visitor_name}},

I've been thinking about our conversation at [dinner_name],
particularly what you said about {{specific_topic}}.

{{value_offer}}

I think there's a real opportunity for us to [specific collaboration
angle]. Would love to explore that further whenever timing works
for you.

Warmly,
{{client_name}}

## Brand Voice Guardrails
- More reflective tone ("I've been thinking about...")
- Name a specific collaboration, not a vague "let's connect"
- No urgency language (dinners breed patience)
- Keep under 120 words
  • Save Council/templates/dinner.md.
  • Create Council/templates/ad-hoc.md with the following content:
# Ad-Hoc Follow-Up Template

## Tone
Brief and curiosity-driven. Ad-hoc encounters (airport lounges,
coffee shops, gym) have less shared context, so lean into
curiosity about their world.

## Structure
1. Remind them where you met (they may not remember)
2. Reference their note with genuine curiosity
3. Offer one specific value-add related to their interest
4. Ultra-short close

## Template

Hi {{visitor_name}},

We met briefly at [location/context]. You mentioned
{{specific_topic}} and it piqued my curiosity.

{{value_offer}}

Would love to hear more about what you're building.

{{client_name}}

## Brand Voice Guardrails
- Brevity is king (under 80 words)
- Always remind them of context (they may not remember you)
- Lead with curiosity about their work, not your own
- One value-add maximum
  • Save Council/templates/ad-hoc.md.

Confirm all four templates exist in Obsidian.

  • In Obsidian's left sidebar, expand Council > templates. You should see five files: base-email.html, conference.md, meetup.md, dinner.md, and ad-hoc.md.

How E5 improves these templates over time

These templates are starting points. Each week, the E5 Weekly Refinement agent reviews which templates got the highest reply rates. Templates below 15% reply rate get flagged for revision. Templates above 30% get promoted as the new baseline. Over time, the system converges on the exact phrasing that gets Marcus the most responses.

🙋‍♀️ Not sure which template to use for a contact?

The utm_campaign field on each visitor record encodes the event type (e.g., ai-builders-conf-jun-2026). The C1 agent matches the campaign name against the event categories to select the right template automatically. If the event type is ambiguous, C1 defaults to the ad-hoc template.

Help me understand how the Connection Council selects the right template for each contact

✔️ Awesome, I've got everything!

Great. Double check you have saved all five template files in your Obsidian vault.

ⓧ I'd like to double check the full code

Here is the complete base email template for comparison:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #1a1a1a;">

  <!-- Personalized greeting -->
  <p>Hi {{visitor_name}},</p>

  <!-- Main body: topic-specific follow-up -->
  <p>{{specific_topic}}</p>

  <!-- Value offer -->
  <p>{{value_offer}}</p>

  <!-- Sign-off -->
  <p>Best,<br>{{client_name}}</p>

  <!-- CAN-SPAM compliant footer -->
  <hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
  <footer style="font-size: 12px; color: #666;">
    <!-- Physical address (CAN-SPAM requirement) -->
    <p>{{physical_address}}</p>

    <!-- One-click unsubscribe (CAN-SPAM requirement) -->
    <p><a href="{{unsubscribe_link}}" style="color: #666;">Unsubscribe from future emails</a></p>

    <!-- EU AI Act Art 50 transparency disclosure -->
    <p>This message was drafted with AI assistance and personally reviewed by Marcus before sending.</p>
  </footer>

</body>
</html>

And the four category templates:

# Conference Follow-Up Template

## Tone
Professional warmth. Reference the shared experience of the talk/panel.

## Structure
1. Open with the specific talk or session you both attended
2. Reference their note about what they want to discuss
3. Offer a concrete resource (article, tool, intro) related to that topic
4. Close with a low-friction next step (async reply, not a calendar link)

## Template

Hi {{visitor_name}},

It was great connecting at [event_name]. [Reference to shared talk/session
and something specific they said or asked about.]

I thought you might find this useful: [specific resource related to
{{specific_topic}}]. It connects directly to what you mentioned about
[their note].

{{value_offer}}

Would love to hear your thoughts when you get a chance.

Best,
{{client_name}}

## Brand Voice Guardrails
- Adam Grant Give-and-Take register: lead with value, not asks
- No "let's hop on a call" (too aggressive for first follow-up)
- No generic "great to meet you" without specifics
- Keep under 150 words
# Meetup Follow-Up Template

## Tone
Casual and energetic. Meetups are informal, so match that vibe.

## Structure
1. Open with a reference to the meetup topic or venue
2. Mention their specific interest from the note field
3. Suggest a low-key coffee or virtual chat
4. Keep it short (meetup contacts expect brevity)

## Template

Hi {{visitor_name}},

Really enjoyed chatting at [meetup_name] last night. Your take on
{{specific_topic}} stuck with me.

{{value_offer}}

Would you be up for grabbing a coffee sometime this week? Happy to
do virtual if that's easier.

Cheers,
{{client_name}}

## Brand Voice Guardrails
- Casual but not sloppy (no slang, no all-lowercase)
- Coffee invite is the default CTA for meetups
- Reference something specific from their note
- Keep under 100 words
# Dinner Follow-Up Template

## Tone
Intimate and thoughtful. Dinners create deeper connections than
large events. Honor that with a more personal register.

## Structure
1. Open with something specific from the dinner conversation
2. Name the collaboration angle they mentioned
3. Propose a concrete next step toward that collaboration
4. Warm, unhurried sign-off

## Template

Hi {{visitor_name}},

I've been thinking about our conversation at [dinner_name],
particularly what you said about {{specific_topic}}.

{{value_offer}}

I think there's a real opportunity for us to [specific collaboration
angle]. Would love to explore that further whenever timing works
for you.

Warmly,
{{client_name}}

## Brand Voice Guardrails
- More reflective tone ("I've been thinking about...")
- Name a specific collaboration, not a vague "let's connect"
- No urgency language (dinners breed patience)
- Keep under 120 words
# Ad-Hoc Follow-Up Template

## Tone
Brief and curiosity-driven. Ad-hoc encounters (airport lounges,
coffee shops, gym) have less shared context, so lean into
curiosity about their world.

## Structure
1. Remind them where you met (they may not remember)
2. Reference their note with genuine curiosity
3. Offer one specific value-add related to their interest
4. Ultra-short close

## Template

Hi {{visitor_name}},

We met briefly at [location/context]. You mentioned
{{specific_topic}} and it piqued my curiosity.

{{value_offer}}

Would love to hear more about what you're building.

{{client_name}}

## Brand Voice Guardrails
- Brevity is king (under 80 words)
- Always remind them of context (they may not remember you)
- Lead with curiosity about their work, not your own
- One value-add maximum

Your email delivery pipeline is ready. Marcus's domain is verified, the base template satisfies three compliance frameworks, and four category templates give the Connection Council a strong starting voice for every type of event. Next up, you will scaffold the full 25-agent Connection Council in Obsidian and wire everything together for the end-to-end test.

Scaffold the Obsidian Vault and 25-Agent Connection Council

Your infrastructure is live. The landing page captures visitors, Supabase stores them, and Resend delivers emails. Now you need the brain that ties it all together.

The Connection Council is a team of 25 specialized AI agents organized into 5 squads. Each agent has a single job, a clear input/output contract, and a self-improvement hook that lets it get better over time. In this step, you will scaffold the full vault structure, seed Marcus's identity, and create every agent module.

In this step, get ready to:
  • Create the vault folder structure for agents, config, runs, and refinement.
  • Seed Marcus's client identity and brand voice register.
  • Build 25 agent prompt modules and a manual entry path.
Create the vault folder structure

The Council needs dedicated spaces for agents, configuration, execution logs, and refinement data. Your vault already has Council/adrs/, Council/templates/, and the architecture files from earlier steps.

  • In Obsidian, right-click the Council folder in the left sidebar file explorer.
  • Select New folder.
  • Name it agents and press Enter.
  • Repeat to create three more folders inside Council: config, runs, and refinement.

What is each folder for?

  • agents/ holds the 25 individual agent prompt modules (one markdown file each).
  • config/ stores Marcus's identity and brand voice rules that every agent references.
  • runs/ captures execution logs every time the Council runs.
  • refinement/ holds weekly performance notes and template improvement data from E5.

Your vault's file explorer should now show agents, config, runs, and refinement nested inside Council alongside the existing adrs and templates folders.

Folders not appearing?

Obsidian may hide empty folders. Create a placeholder note inside each folder (you can delete it later once real files exist). If you accidentally created a folder outside Council, drag it into the correct parent in the file explorer.

Help me fix my Obsidian vault folder structure

Seed Marcus's identity and brand voice

Every agent in the Council references two config files to keep drafts consistent. The identity file tells agents who Marcus is. The voice register tells them how he sounds.

  • Right-click the Council/config folder and select New note.
  • Name it client-identity.yaml and paste the following content:
# Marcus's Client Identity Configuration
# Used by all Council agents to maintain consistent personalization

name: "Marcus Chen"
title: "Technology Leader & Advisor"
one_liner: "I help founders turn AI experiments into production systems"

bio: |
  Technology leader with 15 years building and scaling engineering teams.
  Currently advising early-stage AI startups on infrastructure and go-to-market.
  Speaker on AI systems architecture and engineering leadership.

niche: "AI infrastructure, developer tools, engineering leadership"

boundaries:
  - "No cold outreach to people I haven't met in person"
  - "No follow-ups on weekends"
  - "No more than 2 emails to any contact without a reply"
  - "Never share contact info with third parties"

three_year_vision: |
  Build an advisory practice serving 10-15 AI startups simultaneously.
  Become the go-to connector in the AI infrastructure space.
  Publish a book on scaling AI teams.

event_categories:
  - conference
  - meetup
  - dinner
  - ad-hoc

What does this file do?

This is the single source of truth for who Marcus is. Every agent reads this file before drafting anything. The boundaries field is critical. It prevents agents from sending follow-ups on weekends or exceeding two unreplied emails to the same person.

The event_categories field maps directly to the 4 follow-up templates you seeded earlier. When a visitor scans the QR at a conference, the UTM tag tells agents which template to start from.

  • Now create another note in Council/config named brand-voice-register.md with this content:
# Brand Voice Register

## Core Philosophy: Adam Grant Give-and-Take

Every interaction leads with value. Marcus gives before he asks.
The register prioritizes generosity, curiosity, and specificity.

## Voice Characteristics

- **Warm but direct**: No fluff, but never cold
- **Specific**: Reference the actual conversation, never generic
- **Generous**: Offer something concrete (resource, intro, insight)
- **Curious**: Ask one genuine question
- **Brief**: Respect their time. 3-5 sentences max for first touch.

## Channel Rules

| Channel | Tone | Length | Sign-off |
|---------|------|--------|----------|
| Email (first touch) | Professional-warm | 3-5 sentences | Best, Marcus |
| Email (second touch) | Casual-helpful | 2-3 sentences | Marcus |
| LinkedIn DM | Conversational | 1-2 sentences | None |

## Banned Phrases

- "I hope this email finds you well"
- "Just following up"
- "Let's circle back"
- "Pick your brain"
- "Synergy" or "leverage"
- "As per our conversation"
- "I'd love to connect"
- Any generic opener that could apply to anyone

## Self-Improvement Thresholds

- **Flag for review**: Templates with < 15% reply rate over 10+ sends
- **Promote to default**: Templates with > 30% reply rate over 10+ sends
- **Weekly refinement**: E5 analyzes reply rates every Sunday, proposes template edits

Why these thresholds?

The 15% and 30% thresholds give the system clear triggers for self-improvement. If a template drops below 15% reply rate after 10+ sends, E5 flags it during the weekly refinement cycle. If a template exceeds 30%, E5 promotes its patterns into other templates.

This means the system gets better at Marcus's specific audience over time without any manual tuning.

  • Confirm both files appear under Council/config/ in Obsidian's file explorer.

You should see client-identity.yaml and brand-voice-register.md listed inside the config folder.

File showing in the wrong folder?

If Obsidian created the note at the vault root instead of inside Council/config/, drag it into the correct folder using the file explorer sidebar. Make sure you right-clicked the config folder before selecting New note.

Help me organize my Obsidian vault files

Create 25 agent prompt modules and the manual entry path

The Connection Council has 5 teams of 5 agents each. Every agent gets its own markdown file with a consistent structure: role, scope, input/output contract, constraints, and a self-improvement hook.

  • Right-click the Council/agents folder and create a new note named C1.md. This is the Follow-Up Drafter, the most important agent in the system. Paste this content:
# Agent C1: Follow-Up Drafter

## Role

Draft personalized follow-up emails for new connections within 24 hours of meeting.

## Scope

- Input: Visitor record (name, email, note, event category, UTM data)
- Output: Complete email draft ready for client approval
- Does NOT send emails (that is D1's approval + pg_net trigger)

## Input/Output Contract

### Input

- visitor record from Supabase (name, email, linkedin, note, event_category)
- client-identity.yaml (for personalization context)
- brand-voice-register.md (for tone and constraints)
- Matching category template from Council/templates/

### Output

- subject: string (max 50 chars, specific to conversation)
- body: string (follows brand voice, 3-5 sentences)
- channel: "email" | "linkedin"
- confidence: float (0-1, how well the draft matches available context)

## Constraints

- MUST reference something specific from the visitor's note field
- MUST lead with value (resource, insight, or introduction offer)
- MUST NOT use any banned phrases from brand-voice-register.md
- MUST include one genuine question to invite reply
- MUST stay under 5 sentences for first touch
- MUST flag low-confidence drafts (< 0.6) for human review before approval

## Self-Improvement Hook

After each weekly E5 refinement cycle, C1 checks:
- Which of my drafts got replies vs which were ignored?
- What patterns exist in high-reply drafts (length, opener style, value type)?
- Update my default approach based on what works for this specific client.

Why this structure matters

Every agent file follows the same five sections. This consistency means Claude can read any agent file and immediately know what that agent does, what it expects as input, what it produces, and how it improves. The self-improvement hook is what makes the system get better over time without manual intervention.

  • Create another note in Council/agents named C4.md. This is the Second-Touch Drafter for Day 7 non-responders:
# Agent C4: Second-Touch Drafter

## Role

Draft value-add second-touch emails for connections who have not replied within 7 days.

## Scope

- Input: Stale send record (original draft, visitor info, days since sent)
- Output: Second-touch draft that adds new value without nagging
- Triggered when stale_sends view flags a non-responder

## Input/Output Contract

### Input

- stale_sends record (visitor_name, original_subject, original_body, sent_at)
- client-identity.yaml (for context on what value Marcus can offer)
- brand-voice-register.md (second-touch channel rules)

### Output

- subject: string (new angle, never "Re:" of original)
- body: string (2-3 sentences, leads with a new resource or insight)
- channel: "email"
- value_type: "resource" | "introduction" | "insight" | "event_invite"

## Constraints

- MUST NOT reference the original email or say "following up"
- MUST offer something new and concrete (article, intro, event)
- MUST respect the 2-email boundary (no third touch without reply)
- MUST stay under 3 sentences
- Tone: casual-helpful per brand-voice-register.md second-touch rules

## Self-Improvement Hook

Track which value_type gets the highest reply rate on second touches.
If "resource" outperforms "introduction" by 2x, default to resource-first.
  • Create D1.md in Council/agents. This is the Human Gate that ensures Marcus always has final approval:
# Agent D1: Human Gate

## Role

Present drafted messages to Marcus for approval, edit, or rejection.
Nothing sends without Marcus's explicit one-tap approval.

## Scope

- Input: Draft from C1 or C4 (subject, body, channel, confidence)
- Output: Approved/edited/rejected status written to sends table
- Controls the final step before pg_net trigger fires

## Input/Output Contract

### Input

- draft record from sends table (status: 'pending')
- visitor context (name, event, note)
- confidence score from drafting agent

### Output

- status: "approved" | "rejected"
- edited_body: string | null (if Marcus made changes)
- rejection_reason: string | null (feeds back to E5 for refinement)

## Constraints

- MUST surface low-confidence drafts (< 0.6) with a warning flag
- MUST present visitor context alongside the draft (not just the email)
- MUST allow inline editing without leaving the approval interface
- MUST log rejection reasons for E5 weekly analysis
- MUST NOT auto-approve anything regardless of confidence score

## Self-Improvement Hook

Track edit frequency. If Marcus edits > 50% of drafts from a specific
template category, flag that category for E5 refinement priority.
  • Create D5.md in Council/agents. This is the Standards Gate that enforces legal compliance:
# Agent D5: Standards Gate

## Role

Validate every outbound message against CAN-SPAM, GDPR, and EU AI Act Art 50
before it enters the sends table.

## Scope

- Input: Draft message (subject, body, recipient_email, channel)
- Output: Pass/fail with specific violation citations
- Blocks non-compliant drafts from reaching D1 approval queue

## Input/Output Contract

### Input

- draft message (subject, body, channel)
- recipient record (email, consent_timestamp, consent_text)
- Resend base template (for footer verification)

### Output

- compliant: boolean
- violations: array of { rule, description, fix_suggestion }
- art50_disclosure_present: boolean
- unsubscribe_link_present: boolean
- physical_address_present: boolean

## Constraints

- CAN-SPAM (15 U.S.C. 7701-7713): Physical address + unsubscribe in every email
- GDPR (Arts 5/6): Consent timestamp must exist, lawful basis documented
- EU AI Act Art 50: Disclosure footer must be present in every email
- MUST block any draft missing required elements
- MUST cite the specific regulation violated in the failure message
- MUST verify consent_timestamp is not null before allowing send

## Self-Improvement Hook

Track which violations occur most frequently.
If a specific template consistently triggers Art 50 failures,
flag the template for structural fix rather than per-draft patching.
  • Create E5.md in Council/agents. This is the Weekly Refinement agent that makes the entire system self-improving:
# Agent E5: Weekly Refinement

## Role

Analyze reply rates, rejection patterns, and template performance weekly.
Propose concrete improvements to templates and agent behaviors.

## Scope

- Input: All sends from past 7 days with reply/open status
- Output: Refinement report + specific template edit proposals
- Runs every Sunday as part of the weekly maintenance cycle

## Input/Output Contract

### Input

- sends records (last 7 days): status, reply_received, open_tracked
- responses records: sentiment, reply_length
- D1 rejection logs: rejection_reason, template_category
- Current templates from Council/templates/
- brand-voice-register.md thresholds

### Output

- weekly_report.md saved to Council/refinement/
- template_proposals: array of { template, change_type, before, after, rationale }
- threshold_alerts: templates below 15% or above 30% reply rate
- agent_feedback: specific notes for C1, C4 self-improvement hooks

## Constraints

- MUST NOT change templates directly (proposes changes for human review)
- MUST require 10+ sends before flagging a template (avoid small-sample noise)
- MUST compare performance across event categories separately
- MUST document rationale for every proposed change
- MUST preserve brand voice constraints even when optimizing for replies

## Self-Improvement Hook

Track whether proposed changes actually improve reply rates after adoption.
If a proposed change decreases performance, flag the proposal pattern
and adjust the refinement criteria for next cycle.

What about the other 20 agents?

You have just created the 5 priority agents. The remaining 20 follow the exact same structure. Here is the full roster by team:

  • A-Team (Landing): A1 Page Monitor, A2 Turnstile Validator, A3 UTM Parser, A4 Form Sanitizer, A5 Mobile Optimizer.
  • B-Team (Capture): B1 Visitor Recorder, B2 Deduplicator, B3 Event Tagger, B4 LinkedIn Enricher, B5 Context Extractor.
  • C-Team (Synthesis): C2 Template Selector, C3 Subject Line Crafter, C5 Channel Router.
  • D-Team (Gate): D2 Tone Checker, D3 Length Enforcer, D4 Personalization Scorer.
  • E-Team (Tracking): E1 Open Tracker, E2 Reply Detector, E3 Sentiment Scorer, E4 Stale Send Flagger.

Create each agent file in Council/agents/ following the same five-section structure: Role, Scope, Input/Output Contract, Constraints, Self-Improvement Hook.

  • Create the remaining 20 agent files (A1.md through A5.md, B1.md through B5.md, C2.md, C3.md, C5.md, D2.md through D4.md, E1.md through E4.md) in Council/agents/.
  • Use the C1 file as your template. Each agent needs its own role description, specific inputs/outputs, constraints relevant to its job, and a self-improvement hook tied to measurable outcomes.

Use Claude to generate the remaining agents

Since you have Claude Desktop with filesystem MCP, you can ask Claude to generate the remaining 20 agent files using C1 as the reference pattern. Point Claude at Council/agents/C1.md and ask it to create the rest following the same structure. Review each to confirm the roles match the team descriptions above.

  • Finally, create manual-entry.md in the Council root folder (not inside agents/). This is the prompt Marcus uses when someone does not scan the QR:
# Manual Entry Prompt

Use this prompt when Marcus meets someone who did NOT scan the QR code.
Marcus dictates the visitor's info and conversation context to Claude,
which creates the visitor record in Supabase and drafts the follow-up.

## Prompt Template

I just met someone at [event]. Here is their info:

- Name: [their name]
- Email: [their email, if exchanged]
- LinkedIn: [their handle, if mentioned]
- What we discussed: [brief context from the conversation]
- What I could offer them: [resource, intro, or insight]
- Event category: conference | meetup | dinner | ad-hoc

## Instructions for Claude

1. Create a visitor record in Supabase with the provided info
2. Set utm_source to "manual", utm_campaign to the event name
3. Pass the record to C1 (Follow-Up Drafter) with full context
4. C1 drafts using the matching category template
5. Draft enters sends table with status 'pending' for D1 approval

## When to Use

- Contact gave a business card instead of scanning
- Brief hallway conversation without time to show QR
- Referral from a mutual connection (not met in person yet)
- Post-event recall of someone Marcus forgot to capture live

Why a manual entry path?

Not every connection happens at the QR. Sometimes the conversation is too fast, or the person shares a business card instead. The manual entry prompt ensures no connection falls through the cracks. Marcus dictates to Claude later, and the same pipeline handles it from there.

  • Verify your vault structure in Obsidian's file explorer. Expand the Council folder.

You should see: agents/ (with 25 files), adrs/ (6 files from earlier), config/ (2 files), refinement/ (empty), runs/ (empty), templates/ (4 files from earlier), plus manual-entry.md, architecture.md, and execution-plan.md at the root.

Missing agent files or wrong count?

Open the agents/ folder and count the files. You need exactly 25: A1 through A5, B1 through B5, C1 through C5, D1 through D5, and E1 through E5. If some are missing, check whether Claude via MCP created them in the wrong directory or with unexpected filenames.

Help me verify my 25 agent files are all in the right place

Your Connection Council is fully scaffolded. 25 agents organized into 5 teams, each with clear contracts and self-improvement hooks. Next up, you will wire the entire pipeline together and run the end-to-end test.

Run End-to-End Pipeline Test and Generate QR Code

Every piece of the networking engine is deployed and configured. The landing page captures contacts, Supabase stores and triggers, Resend delivers, and the Connection Council drafts personalized follow-ups.

Now you need to prove the entire chain works as one continuous flow. In this step, you will generate Marcus's first branded QR code, run a real submission through every link in the pipeline, and verify the system is production-ready with a 9-point compliance checklist.

In this step, get ready to:
  • Generate a branded QR code with event attribution using a Node.js script.
  • Execute the full automated pipeline from QR scan to delivered email.
  • Run a 9-point compliance and health checklist and document the results.
Generate the first QR code with event attribution

Every QR code Marcus shows at an event needs to encode the landing page URL plus UTM parameters that identify which event the contact came from. This lets the system attribute every submission to a specific conference or meetup.

  • Install the qrcode npm package by running this command in your project root:
npm install qrcode

What is this package?

The qrcode package generates QR code images as PNG files from any text or URL. It supports high error correction (level H), which means the QR still scans even if up to 30% of the image is obscured or damaged.

🙋‍♀️ Install failing?

  • Make sure Node.js is installed by running node --version in your terminal.
  • Confirm you are in the correct project directory where your index.html lives.

help me fix my npm install

  • Create a file called generate-qr.js in your project root by running:
touch generate-qr.js
  • Open generate-qr.js and paste the following code:
// Generate branded QR codes with UTM event attribution
const QRCode = require('qrcode');

// Get the event campaign name from command line argument
const campaign = process.argv[2];

if (!campaign) {
  console.log('Usage: node generate-qr.js <utm_campaign>');
  console.log('Example: node generate-qr.js ai-builders-conf-jun-2026');
  process.exit(1);
}

// Build the landing page URL with full UTM tracking
const baseUrl = 'https://[[PAGES_DOMAIN="your-project.pages.dev"]]';
const url = `${baseUrl}?utm_source=qr&utm_medium=event&utm_campaign=${campaign}`;

// Output filename tied to the campaign for easy identification
const filename = `qr-${campaign}.png`;

// Generate 1024px QR code with high error correction
QRCode.toFile(filename, url, {
  width: 1024,
  errorCorrectionLevel: 'H',
  margin: 2,
  color: {
    dark: '#1a1a2eff',
    light: '#ffffffff'
  }
}, (err) => {
  if (err) {
    console.error('QR generation failed:', err);
    process.exit(1);
  }
  console.log(`QR code saved: ${filename}`);
  console.log(`URL encoded: ${url}`);
  console.log('Save this image as your phone wallpaper for the event.');
});

What does this code do?

The script takes one argument (the event campaign name) and builds a full URL with UTM parameters baked in. Every QR scan is automatically attributed to the specific event Marcus attended.

  • utm_source=qr identifies that the contact came from a QR code scan.
  • utm_medium=event confirms the channel was an in-person event.
  • utm_campaign names the specific conference or meetup.
  • errorCorrectionLevel: 'H' means the QR still scans even with up to 30% obscured. Useful on phone wallpapers with overlaid widgets.
  • width: 1024 produces a crisp image at any zoom level on a phone screen.
  • Save generate-qr.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before running it in the next command.

ⓧ I'd like to double check the full code

// Generate branded QR codes with UTM event attribution
const QRCode = require('qrcode');

// Get the event campaign name from command line argument
const campaign = process.argv[2];

if (!campaign) {
  console.log('Usage: node generate-qr.js <utm_campaign>');
  console.log('Example: node generate-qr.js ai-builders-conf-jun-2026');
  process.exit(1);
}

// Build the landing page URL with full UTM tracking
const baseUrl = 'https://[[PAGES_DOMAIN="your-project.pages.dev"]]';
const url = `${baseUrl}?utm_source=qr&utm_medium=event&utm_campaign=${campaign}`;

// Output filename tied to the campaign for easy identification
const filename = `qr-${campaign}.png`;

// Generate 1024px QR code with high error correction
QRCode.toFile(filename, url, {
  width: 1024,
  errorCorrectionLevel: 'H',
  margin: 2,
  color: {
    dark: '#1a1a2eff',
    light: '#ffffffff'
  }
}, (err) => {
  if (err) {
    console.error('QR generation failed:', err);
    process.exit(1);
  }
  console.log(`QR code saved: ${filename}`);
  console.log(`URL encoded: ${url}`);
  console.log('Save this image as your phone wallpaper for the event.');
});
  • Generate the first QR code for a test event by running:
node generate-qr.js ai-builders-conf-jun-2026
  • Confirm the output shows QR code saved: qr-ai-builders-conf-jun-2026.png and the full URL with UTM parameters.
  • Verify the file exists by running:
ls qr-ai-builders-conf-jun-2026.png
  • Open the generated qr-ai-builders-conf-jun-2026.png file and scan it with your phone camera to verify the URL resolves to your landing page.
  • Save the QR image as your phone wallpaper (or lock screen) for testing the full pipeline in the next substep.

QR code not generating?

  • Make sure you ran npm install qrcode in the same directory where generate-qr.js lives.
  • Check that you passed the campaign name as an argument. Running node generate-qr.js without an argument shows usage instructions.
  • If the file generates but will not scan, verify your Cloudflare Pages domain in the script matches your actual deployed URL.

help me debug my QR code generation script

Execute the full automated pipeline

This is the moment of truth. You will walk through the entire journey that a new connection experiences, from scanning Marcus's QR code to receiving the follow-up email in their inbox.

  • Open your landing page URL in a browser. Scan the QR code you just generated, or paste the URL directly.
  • Confirm that Cloudflare Turnstile verifies you silently. The form should become active without any visible challenge.
  • Fill the form with test data: name Anna, a real email address you control, and the note interested in MCP servers.
  • Check the GDPR consent checkbox and submit the form.
  • Switch to the Supabase Table Editor from earlier.
  • Select the visitors table and verify Anna's record appears with utm_source=qr, utm_medium=event, and utm_campaign=ai-builders-conf-jun-2026.

What does correct UTM attribution look like?

Anna's visitor record should have all three UTM fields populated from the QR URL. This proves the landing page correctly captures URL parameters and stores them alongside the contact data.

Later, this attribution tells Marcus exactly which event produced each connection.

  • Return to Claude Desktop and invoke agent C1 (Follow-Up Drafter) via MCP. Pass it Anna's visitor record, the client identity from Council/config/client-identity.yaml, the brand voice register from Council/config/brand-voice-register.md, and the conference template from Council/templates/conference.md.
  • Review the draft Claude produces. It should reference Anna's note about MCP servers, match Marcus's voice register, and follow the conference template structure.
  • INSERT the draft into the sends table with status='pending' by running this SQL in the Supabase SQL Editor:
-- Insert the Claude-drafted follow-up for approval
INSERT INTO sends (client_id, visitor_id, recipient_email, subject, body, status)
VALUES (
  '[[CLIENT_UUID="your client UUID from the visitors table"]]',
  '[[VISITOR_UUID="Anna's visitor UUID from the visitors table"]]',
  '[[TEST_EMAIL="the email address you used for Anna"]]',
  'Great meeting you at AI Builders Conf',
  '[[DRAFT_BODY="paste the full draft body Claude generated"]]',
  'pending'
);

Why insert manually for this test?

In production, Claude will INSERT the draft automatically via MCP. For this foundation test, you insert it manually to isolate and verify each link in the chain independently.

If the email arrives, you know the trigger and delivery work regardless of how the draft got there.

  • Trigger the approval by running this SQL to simulate Marcus tapping Approve:
-- Simulate Marcus approving the follow-up
UPDATE sends
SET status = 'approved'
WHERE recipient_email = '[[TEST_EMAIL="the email address you used for Anna"]]'
  AND status = 'pending';

What happens when status changes to approved?

The pg_net trigger you created in Step 4 intercepts this UPDATE. It fires an async HTTP POST to the Resend API with the draft subject, body, and recipient email.

In the same transaction, it sets status='sent' and records sent_at. Marcus taps one button. The email arrives seconds later.

  • Check your email inbox. Within 30 seconds you should receive the follow-up email from Marcus's verified domain.
  • Verify the email contains the correct CAN-SPAM footer with physical address and one-click unsubscribe link.
  • Verify the EU AI Act Art 50 disclosure is present in the footer. It should state the message was drafted with AI assistance and personally reviewed by Marcus.
  • Return to the sends table in Supabase. Confirm the row now shows status='sent' with a populated sent_at timestamp.
  • Verify the second-touch logic by querying the stale_sends view. Since Anna's email was just sent (not 7 days ago), it should NOT appear yet:
-- Check that Anna is NOT flagged yet (sent less than 7 days ago)
SELECT * FROM stale_sends;
  • Confirm the query returns zero rows. After 7 days with no entry in the responses table, Anna will appear here for agent C4 to draft a value-add second touch.

Email not arriving?

  • Check the sends table. If status is still approved (not sent), the pg_net trigger did not fire. Verify the trigger exists and that the Resend API key is set correctly.
  • If status shows sent but no email arrived, check Resend's dashboard under Logs for delivery status. The email may be in your spam folder.
  • Verify your Resend API key is stored correctly by running SELECT current_setting('app.resend_api_key', true); in the SQL Editor.

help me debug why my pg_net trigger is not sending the email through Resend

Run the 9-point compliance and health checklist

Before handing this system to Marcus, you need documented proof that every compliance requirement and operational safeguard passes. This checklist becomes the system's acceptance test.

  • Work through each of the 9 checks below. For each one, confirm it passes and record the result.
  • Turnstile blocking bots: Open your landing page in a private/incognito window. Confirm the Turnstile widget activates before the form can submit.
  • Consent timestamp present: Check Anna's row in the visitors table. A consent_at timestamp must be populated.
  • CAN-SPAM footer correct: Open the test email. Confirm the physical address appears at the bottom.
  • Unsubscribe link works: Click the unsubscribe link in the test email. Confirm it loads a working page.
  • Art 50 disclosure present: Confirm the email footer contains the AI assistance disclosure statement.
  • RLS blocks cross-visitor reads: In the SQL Editor, switch to the anon role and run SELECT * FROM visitors; to confirm it returns zero rows.
  • Keep-alive Worker responds: Check your Worker's logs in the Cloudflare dashboard to confirm the daily cron has fired at least once.
  • pg_net auto-send functional: Already verified. The test email arrived after you set status='approved'.
  • UTM attribution correct per event: Already verified. Anna's visitor record contains the correct ai-builders-conf-jun-2026 campaign value.
  • Document the results by creating Council/runs/foundation-test.md in your Obsidian vault with the following content:
# Foundation Test Results

Date: 2026-06-01
Tester: [Your name]
Event QR: ai-builders-conf-jun-2026
Test Contact: Anna (interested in MCP servers)

## 9-Point Checklist

1. [x] Turnstile blocking bots - Widget activates, form protected
2. [x] Consent timestamp present - consent_at populated on visitor record
3. [x] CAN-SPAM footer correct - Physical address visible in email footer
4. [x] Unsubscribe link works - Link loads functional unsubscribe page
5. [x] Art 50 disclosure present - AI assistance statement in email footer
6. [x] RLS blocks cross-visitor reads - Anon role returns zero rows
7. [x] Keep-alive Worker responds - Cron trigger confirmed in Worker logs
8. [x] pg_net auto-send functional - Email delivered on status change to approved
9. [x] UTM attribution correct - ai-builders-conf-jun-2026 stored in visitor record

## Pipeline Summary

QR > Landing Page > Turnstile > Supabase INSERT > Claude C1 Draft > sends (pending) > Approve > pg_net trigger > Resend delivers > stale_sends monitors

All checks passed. System ready for production use.

Why document test results in the vault?

This file becomes the system's proof of acceptance. When Marcus asks how you know it works, or when you onboard a second client, foundation-test.md provides the documented evidence.

Agent E5 (Weekly Refinement) also references this baseline when measuring system performance over time.

  • Save Council/runs/foundation-test.md in your Obsidian vault.

✔️ All 9 checks passed

The system is production-ready. Marcus can start using it at his next event.

ⓧ One or more checks failed

Review each failed check individually. The most common issues are:

  • RLS failure: re-run your policy creation SQL from Step 4.
  • Missing consent timestamp: check that your landing page JavaScript populates the field on form submit.
  • Worker cron not firing: verify the cron trigger is set to 0 8 * * * in your Worker configuration.

Still stuck on a failing check?

help me debug my 9-point compliance checklist failures

ⓧ Email arrived but missing footer elements

Your Resend template needs updating. Check that your base template includes:

  • A physical mailing address (CAN-SPAM requirement).
  • A one-click unsubscribe link that actually works.
  • The EU AI Act Art 50 disclosure line in the footer.

Update your template in Resend and re-run the approval test to verify the corrected email.

Marcus's networking engine is fully verified and production-ready. Next up, you will tackle the Secret Mission and build a mobile-installable approval dashboard with real-time updates and self-healing health monitoring.

Secret mission

PWA Approval Dashboard + Self-Healing Health Monitor

Marcus's engine drafts follow-ups, but he still needs to approve them. Right now that means opening Supabase and running SQL. Build him a mobile-installable approval dashboard that shows pending drafts as swipeable cards, sends on one tap, and alerts him before anything breaks.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The infrastructure runs on free tiers (Cloudflare, Supabase, Resend), so there are no ongoing cloud costs. Your only recurring cost is the Claude Max 20x subscription at $200/mo.

Cost warning

Your Claude Max 20x subscription charges $200/mo regardless of whether you use this project. If you built this project solely to learn and do not plan to keep using the networking engine, consider downgrading or cancelling your Claude subscription to avoid ongoing charges.

The Cloudflare, Supabase, and Resend resources all run on free tiers and will not incur charges unless you explicitly upgrade.

Resources you used:

  • Cloudflare Pages projects (landing page + dashboard).
  • Cloudflare Workers (keep-alive cron + /health endpoint).
  • Cloudflare Turnstile widget.
  • Cloudflare Web Analytics.
  • Supabase project (tables, RLS policies, pg_net trigger, stale_sends view).
  • Resend verified domain and API key.
  • GitHub repository.
  • Local project files (Obsidian vault, scripts, QR code, dashboard folder).

Keep everything running

No action needed. Choose this if Marcus is actively using the networking engine at events. The system self-improves weekly via E5 refinement.

  • All Cloudflare resources (Pages, Workers, Turnstile, Analytics) remain on the free tier with no charges.
  • The keep-alive Worker prevents Supabase from pausing due to inactivity.
  • Resend Free allows 3,000 emails/month and 100/day.
  • Your Claude Max 20x subscription continues at $200/mo. This is your only cost.
  • The Obsidian vault, Node.js, and Git remain installed on your machine.

Pause - I'll come back to this later

Shut down running processes to let Supabase sleep. Resume anytime with zero data loss.

  • In the Cloudflare dashboard, navigate to your keep-alive Worker. Open its settings and disable the cron trigger. This stops the daily ping, allowing Supabase to pause after 1 week of inactivity.
  • Your Supabase project will pause automatically after 1 week with no requests. All data is preserved. You can resume it from the Supabase dashboard at any time.
  • Cloudflare Pages, Turnstile, and the /health Worker stay live but idle at no cost.
  • To resume later, re-enable the keep-alive Worker cron trigger. Your Supabase project will unpause on first request.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent and cannot be undone.

Delete your Cloudflare Pages projects:

  • In the Cloudflare dashboard, go to Workers & Pages.
  • Click on your landing page project, then go to Settings and click Delete project.
  • Repeat for your dashboard project.

Delete your Cloudflare Workers:

  • In Workers & Pages, click on your keep-alive Worker. Go to Settings, scroll to the bottom, and delete it.
  • Repeat for your /health Worker.

Delete your Turnstile widget:

  • In the Cloudflare dashboard, go to Turnstile. Find your widget and delete it.

Delete your Supabase project:

  • In the Supabase dashboard, open your project. Go to Settings, then General. Click Delete project at the bottom. This removes all tables, policies, triggers, and data permanently.

Revoke your Resend API key and remove the domain:

  • In the Resend dashboard, go to API Keys. Click the more options menu next to your key and select Remove API Key.
  • Go to Domains and remove your verified sending domain.

Delete your GitHub repository:

  • On GitHub, navigate to your repository. Go to Settings, scroll to the Danger Zone, and click Delete this repository.

Delete local project files:

  • Remove your project folder and Obsidian vault by running the appropriate command for your system.

macOS

  • Delete your project files by running:
cd ~/Desktop
rm -rf Council/
rm -f generate-qr.js index.html qr-ai-builders-conf-jun-2026.png
rm -rf dashboard/

Windows

  • Delete your project files by running:
cd ~\Desktop
rmdir /s /q Council
del generate-qr.js index.html qr-ai-builders-conf-jun-2026.png
rmdir /s /q dashboard

That's a Wrap!

That's a Wrap!

Nice work. You just built Marcus a self-running networking engine that turns every conference handshake into a real connection.

You've learned how to:

  • Document architecture decisions (ADRs + Mermaid diagrams) and design a parallel execution plan so any developer can extend the system without needing you.
  • Deploy a full automated pipeline: QR scan to a bot-protected landing page on Cloudflare Pages, into a Supabase CRM with Row Level Security and a pg_net auto-send trigger, through legally-compliant email delivery via Resend (CAN-SPAM + GDPR + EU AI Act Art 50).
  • Scaffold a 25-agent Connection Council in Obsidian with weekly self-improvement, second-touch automation for non-responders, and a complete handoff package that Marcus runs without you.
  • Built a PWA-installable approval dashboard with real-time updates, a second-touch queue, and a self-healing health monitor that alerts before anything breaks.

Ready to quiz yourself?