How It Works
The UK-universities outreach flow, end to end — from seeding university domains through staff discovery and email verification, to an approval-gated multi-touch campaign that stops the moment a reply arrives. Below the diagram: the runtime topology, the autonomous tick, and the compliance controls behind it.Node types
Stage
Decision
Terminal
Edges
Pass / verified
Fail / blocked
Retry loop (cadence)
Buyer groups
Deans / Heads of SchoolResearch & InnovationAdmissions & RecruitmentIT & Digital
Pipeline stages
1
Homepage + sitemap mining; same-domain crawl (≤10 fetches).Locate Directory Pages
2
DeepSeek extracts named senior staff; published-email backstop.Extract Staff
3
Bounded per-person fetch (≤6) to recover verbatim emails.Follow Profiles
4
Title → buyer group: deans, research, admissions, IT-digital.Classify Roles
5
Insert net-new; email_verified=0; never eligible at insert.Persist Contacts
6
Durable thread, auto-approve, cadence [0,5,8], max 3 touches.Launch Campaign
7
Sub-niche-tailored template per buyer group.Compose Touch
8
Sets wake_at per cadence; resumed by */5 cron tick.Schedule Next
Runtime topology
The same university flow spans three runtimes — a cron Worker, the Render backend, and a shared D1 database — plus the email-send Worker.Cloudflare Worker
Cron-only and intentionally thin. On the weekday schedule agentic-sales-campaign-runner
7 9 * * 1-5 it fires POST /cron/tick {job:"university-outreach-tick"}; every */5 it fires {job:"campaign-resume-due"}. It carries the Render bearer (BACKEND_TOKEN) and does no orchestration of its own.POST /cron/tick (authenticated)
Render · FastAPI + uvicorn
The orchestrator. It compiles the university_staff and campaign LangGraph graphs and runs the four-stage tick — seed → scrape → verify → launch. Free tier idles, so the first request after inactivity cold-starts (~30–60s). Graph checkpoints live in D1.agentic-sales.onrender.com
seedscrapeverifylaunch
read / write app data + checkpoints
Cloudflare D1 · SQLite
The shared database. The Worker reaches it over the native D1 binding; the Render backend reaches the same DB through its REST hub. Companies, contacts, campaign threads, and graph checkpoints all live here.lead-gen-jobs
send_touch → POST /rpc
Cloudflare Worker
The send leg. When the campaign graph reaches its send_touchnode, the backend posts to this Worker's emails
/rpc endpoint, which fronts Cloudflare Email Sending and mirrors each send back into D1.The Next.js app (this site, agentic-sales.xyz) is a fourth caller, not a fourth runtime: it talks to the Render backend through src/lib/langgraph and reads and writes the same lead-gen-jobs D1.
CaveatThe whole tick currently runs synchronously inside one /cron/tick request, so it has to finish under the edge timeout. Overrunning it was the cause of a recent stall — since fixed by per-tick wall-clock budgets that cap how long each stage may run.
The four-stage tick
One synchronous pass — seed, scrape, verify, launch — runs inside a single cron tick, each stage bounded by its own knob.1 · Seed
Idempotent upsert of the static ~157-domain .ac.uk seed into companies, tagged uk-universities. No discovery or brainstorm — the list is fixed.Seed UK universities
Bounded budget: Static seed file (no batch knob)Fail-closed: Re-running the seed never duplicates a company; a bad row is skipped, not invented.
2 · Scrape
Runs the university_staff graph over a small batch of not-yet-scanned universities, persisting net-new contacts as outreach_eligible = NULL (published directory addresses only).Scrape published staff directories
Bounded budget: UNIVERSITY_SCRAPE_BATCH (default 1) under a per-tick wall-clock budget · freshness windows 90 days on success / 14 days when blocked (WAF)Fail-closed: The wall-clock budget fences a slow or WAF-blocked directory so the tick stays under the edge limit; an address not literally on the page is nulled, never guessed.
3 · Verify
The only code path that writes outreach_eligible for this vertical. Render blocks SMTP port 25, so the published-email fallback accepts {valid, catchall, unknown} for non-role addresses.Verify pending contacts
Bounded budget: UNIVERSITY_VERIFY_BATCH (default 30) · strict result=="valid" gate via UNIVERSITY_VERIFY_REQUIRE_SMTPFail-closed: Role mailboxes and any definitely-bad verdict are rejected; a contact stays ineligible (and unsent) until it clears verification.
4 · Launch
Budgeted, round-robin launches of at most one contact per university per tick. Creates auto_approve campaigns; touch 0 sends inline.Launch due campaigns
Bounded budget: UNIVERSITY_LAUNCH_BATCH (default 10) · daily cap CAMPAIGN_VERTICAL_DAILY_CAP (default 20; live canary 2)Fail-closed: Suppression and do-not-contact are honoured before launch and re-checked at send; the per-vertical daily cap stops over-send once it is hit.
Inside the staff scraper
The node pipeline of the university_staff graph, from published directory page to a net-new, provenance-stamped contact.1
load_university
Load the company row and its domain; skip the run when the provenance row is still fresh.2
locate_directory_pages
Bounded crawl (≤10 fetches): homepage + candidate paths + sitemap mining, priority-ranked toward leadership/governance leaf pages.3
extract_staff
DeepSeek JSON extraction, prompt-fenced via wrap_untrusted. Compliance backstop: any email not verbatim on the page is nulled — never pattern-guessed.4
follow_profiles
For email-less named staff, fetch their same-domain profile page (≤6 fetches) to recover a published address.5
classify_roles
Deterministic (no LLM) title → sub_niche mapping into 4 buyer groups: deans-heads-of-school, research-innovation, admissions-recruitment, it-digital.6
persist_contacts
Net-new insert, deduped, with provenance {source_url, matched_title, scraped_at}; email_verified=0, outreach_eligible=NULL.7
mark_scanned
Always writes company_facts provenance (field university_staff.scanned), recording blocked=true on 0-page WAF scans so the queue advances.Compliance guarantee. Published institutional emails only — addresses are read verbatim from the page, never constructed or pattern-guessed. Every stored contact carries its own provenance so it can answer a GDPR Art. 14 “where did you get my data” request.
Email egress
How a drafted touch actually leaves the system — and how replies feed back in.Worker
All outbound mail funnels through one Cloudflare Worker, emails, which fronts Cloudflare Email Sending on vadim.blog. The verified sender is Vadim Nicolai <contact@vadim.blog>, with DKIM and SPF aligned on cf-bounce.vadim.blog. Nothing else in the pipeline talks to the mail provider directly.Single egress point
Send
The campaign graph's send_touch node posts a { op, params }envelope to the worker's POST /rpc endpoint — op send for a single message, op batch for several. send_touch is the only node that sends; every draft before it is held for human approval.From a touch to a send
Compliance
Each send carries a List-Unsubscribe header (one-click, RFC 8058) alongside a visible unsubscribe footer and an optional postal address — covering the CAN-SPAM and PECR obligations for cold outreach.Every message is compliant by construction
Cron
CF Email Sending has no server-side scheduling, so a touch queued for later is written to the D1 Scheduled sends
scheduled_sends table and the email is persisted with provider_message_id='sched_<uuid>'— the foreign key back to that row. The worker's every-minute cron then drains it in-process:* * * * *emails cron fires
The send worker wakes every minute.select due rows
D1Pick up due sends
SELECT … scheduled_sends WHERE status='pending' AND send_at<=now — oldest first, up to a batch limit.dispatch.send(payload) — in-process, no HTTP self-call
sendMark sent + reconcile the timeline
On success the row flips to status='sent' and the originating emails row is reconciled scheduled → sent via its sched_<uuid> id.A failed send bumps
attempt_count and stays pendingfor the next minute's tick, until it succeeds or hits MAX_ATTEMPTS and is marked failed.Inbound
Inbound replies arrive via Cloudflare Email Routing, are parsed from MIME, and are persisted to R2 (raw) and D1 (structured). That stored inbound is what powers stop-on-reply: once a contact answers, the check_reply node ends the sequence rather than sending the next touch.Replies close the loop
Scheduling & cadence
When the university outreach flow runs, how its multi-touch sequence is timed and resumed, and how cron — not a managed queue — drives every durable wake-up.The resume loop
A campaign thread that has sent a touch goes to sleep with a futurewake_at. Nothing pushes it awake — instead the campaign-runner worker polls every 5 minutes and resumes whatever is due:*/5 * * * *campaign-runner cron fires
The thin CF worker wakes on its 5-minute schedule. It holds no state of its own.POST /cron/tick {job:'campaign-resume-due'} · Bearer BACKEND_TOKEN
RenderBackend drains due threads
SELECT … campaign_threads WHERE status='waiting' AND wake_at<=now — processed one at a time (the Render free tier is effectively single-worker, so resumes never fan out).resume thread on the campaign graph
branchSend, then re-arm or finish
The graph sends the touch, then either re-arms a new wake_at and returns to waiting, or reaches a terminalstate (replied / stopped / done) and clears wake_at.Threads still in
waiting are picked up again by the next */5 tick — the drain repeats until every thread is terminal.Durable D1 tables, not Cloudflare QueuesThe "queues" behind this flow are ordinary D1 tables — campaign_threads, scheduled_sends, and followup_jobs — each gated by a
status column and a timestamp index (wake_at / send_at / next_attempt_at) and drained by cron polling. Updates are idempotent, so a redelivered tick is safe to replay (at-least-once). This is deliberately not Cloudflare's managed Queues product — there's no producer/consumer binding or dead-letter queue in the university flow; the cron schedule is the consumer.Cron triggers
The schedules that drive the flow, across three thin CF workers. Each cron line is registered with Cloudflare on wrangler deploy.*/5 * * * *campaign-resume-duecampaign-runner
The resume driver. Every 5 minutes the worker POSTs /cron/tick to the Render backend, which SELECTs campaign_threads WHERE status='waiting' AND wake_at<=now and resumes each due thread — sequentially, never fanning out — so it can send its next touch.7 9 * * 1-5university-outreach-tickcampaign-runner
The weekday-morning (≈09:07 UTC) full funnel run — seed → scrape → verify → launch for the university flow, all inside one synchronous /cron/tick request.13 5 * * *campaign-curatecampaign-runner
Daily 05:13 UTC — promotes high-signal campaign traces into the LangSmith curation dataset.31 5 * * *followup-duecampaign-runner
Daily 05:31 UTC — generates the day's follow-up drafts (one followup_jobs row per due contact) for human review. This only enqueues work; the followup-runner below is what actually drains it.* * * * *scheduled-send draineremails
Drains the D1 scheduled_sends table every minute and sends each due row in-process — no HTTP self-call. See the Email egress section for the full drain loop.* * * * *followup_jobs drainerfollowup-runner
Drains followup_jobs every minute (queued-and-due rows, plus running rows stale >8 min) and async-kicks the backend /followup/run for each. Kick-and-return because the email_followup graph runs ~110–160s — longer than a Worker's ~100s outbound-fetch cap would allow synchronously.Outreach cadence
A 3-touch sequence on days [0, 5, 8]. Copy is role-tailored per sub_niche.Touch 0Day 0
Sends inline at launch — the moment the campaign thread starts.Touch 1Day 5
Durable interrupt — the */5 cron wakes the thread once wake_at is reached and sends.Touch 2Day 8
Durable interrupt — the */5 cron wakes the thread once wake_at is reached and sends.Compliance & safety
This flow auto-sends without per-message human review, so its lawful basis and compensating controls are documented here.Lawful basis
Outreach relies on UK GDPR Art. 6(1)(f) legitimate interest: B2B contact with named office-holders at institutions, in their professional capacity. It is PECR-aware, and every send carries sender identification and a working one-click opt-out.Compensating controls
Published institutional emails onlyAddresses are taken verbatim from the institution's own published pages (deterministic on-page backstop). Never pattern-guessed or constructed.
Strict eligibility gateA contact is reachable only when outreach_eligible = 1. A NULL value blocks the send; the gate is fail-closed.
Published-email evidence as verify fallbackWhere port-25 SMTP verification is unavailable, the verbatim published-email evidence on the institution's page stands in as the verification fallback.
Per-vertical hard daily capEach vertical carries a hard daily volume cap. The cap is re-checked again at send time, not only at planning.
Suppression + do-not-contact at sendThe suppression list and do-not-contact register are re-checked at the moment of sending, so late additions still take effect.
Stop-on-replyInherited from the campaign graph: any inbound reply ends the sequence for that contact — no further touches are sent.
Soft pacingAt most one contact per university per tick, so volume against any single institution stays gradual.