← back to work.mountaintopvirtual.com
Case study · AI follow-up drafting across two n8n workflows

A follow-up email that drafts itself, but never sends itself.

CollegePlanning 101 helps high school students and their families navigate college admissions, which means its owner, Stephanie, is on the phone constantly — sales calls with prospective families, advising calls with enrolled students, several other call types besides. She was writing a personalized follow-up by hand after every one. I built the system that drafts that email in her own voice the moment the call ends, and deliberately stops one step short of sending it.

Client — CollegePlanning 101 (collegeplanning101.net) Stack — n8n, Fathom, OpenAI API, Gmail API, GoHighLevel Role — architecture, backend, integration, live cutover

The problem

What was actually eating her time

Every call Stephanie takes gets transcribed automatically by Fathom. That part was already solved. What wasn't: turning a transcript into a follow-up email that sounds like her, addressed to the right person, sent from the right context — without her retyping it from scratch every single time. That's a task that's fine in week one and unsustainable once call volume is real.

It's also harder than it looks for one specific reason: Fathom's own webhook doesn't carry the booking contact's identity. When a call ends, the payload has a transcript, a meeting type, and timestamps — no name, no email tied back to the appointment that was actually booked on her GoHighLevel calendar. Any automation built straight off the transcript has an identity problem before it can draft a single word.

What I built

Two workflows, because the booking data and the call data live in different systems

A shared data table that bridges GoHighLevel and Fathom

A separate workflow — CP101 - GHL Appointment Intake — fires on a GHL webhook the instant an appointment is booked, and upserts the contact's name, email, and appointment details into an n8n data table. A second "reschedule" webhook upserts the same row rather than creating a duplicate when a time slot moves. That table is the only reason the second workflow can resolve who was actually on a call.

Resolving an identity Fathom never sends

When the call-completed webhook fires, the workflow computes the call's time window and matches it against that same data table to recover exactly which booked appointment — and therefore which contact — the call belongs to. Nothing downstream can run until that match succeeds.

Drafted in her voice, routed by call type

The routing step reads the call's meeting_type field to decide whether this is a Sales call or a Student call — each with its own branch, since the right follow-up reads completely differently depending on who's on the other end. The transcript, plus a sample of Stephanie's own past emails, goes to OpenAI to draft a reply that sounds like her rather than a template. The result is created directly as a Gmail draft, addressed using the contact pulled from the data table.

The judgment call: drafted, never sent

The system could close the loop completely on its own. I built it so it doesn't.

Every piece needed to auto-send was already in place — the draft, the contact, the Gmail credential. I added one deliberate gap instead: the workflow notifies Stephanie that a draft is ready, and she reads it, edits if needed, and sends it herself. A transcript-to-email pipeline can get tone, context, or a sensitive detail from the call wrong in ways that only a human catches before it goes out under her name — and an unreviewed email to a family evaluating a college-admissions service is exactly the kind of mistake that's expensive to make. The automation's job is to remove the blank-page problem, not the final judgment call.

Built against a documented shape, verified against the real thing

Fathom's docs don't publish the literal webhook payload

Fathom's API documentation describes the /meetings REST object in detail, but doesn't publish the exact JSON shape of the webhook itself — so the integration was built against the documented object first, on the assumption the webhook payload matches it closely. Rather than ship on that assumption, I checked the routing field against live call traffic once calls started coming through: confirming the meeting_type values Fathom actually returns match Stephanie's calendar names in GoHighLevel — all 23 of them — before trusting that field over the title-keyword fallback the Code node falls back to if it's ever blank.

The result

The live canvas

n8n canvas for the CP101 Fathom Call Follow-Up Drafts workflow, showing the webhook trigger, duplicate check, routing logic, appointment-matching branch, and parallel Sales/Student OpenAI drafting branches that each end in a Gmail draft and a Stephanie notification.

CP101 - Fathom Call Follow-Up Drafts, live in production. The Sales and Student branches run the identical pattern — prepare request, call OpenAI, parse response, create Gmail draft, notify Stephanie — in parallel.

2 + 1
workflows (booking intake, call follow-up) plus one one-time backfill utility
23
GoHighLevel calendar names the routing field was checked against, not assumed
0
emails sent without Stephanie reading and approving the draft first
n8n data table as cross-workflow lookup Sales / Student branching voice-matched via past-email sample Gmail draft, never auto-send

Where it stands

Both workflows are live and published in production. Before the intake workflow existed, I also ran a one-time backfill — paging through all of Stephanie's GoHighLevel calendars to pull every historical appointment into the data table — so the lookup didn't start empty the day the system went live.