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
Mini Map

Pipeline stages

1

Locate Directory Pages

Homepage + sitemap mining; same-domain crawl (≤10 fetches).
2

Extract Staff

DeepSeek extracts named senior staff; published-email backstop.
3

Follow Profiles

Bounded per-person fetch (≤6) to recover verbatim emails.
4

Classify Roles

Title → buyer group: deans, research, admissions, IT-digital.
5

Persist Contacts

Insert net-new; email_verified=0; never eligible at insert.
6

Launch Campaign

Durable thread, auto-approve, cadence [0,5,8], max 3 touches.
7

Compose Touch

Sub-niche-tailored template per buyer group.
8

Schedule Next

Sets wake_at per cadence; resumed by */5 cron tick.

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

agentic-sales-campaign-runner

Cron-only and intentionally thin. On the weekday schedule 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

agentic-sales.onrender.com

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.
seedscrapeverifylaunch
read / write app data + checkpoints
Cloudflare D1 · SQLite

lead-gen-jobs

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.
send_touch → POST /rpc
Cloudflare Worker

emails

The send leg. When the campaign graph reaches its send_touchnode, the backend posts to this Worker's /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

Seed UK universities

Idempotent upsert of the static ~157-domain .ac.uk seed into companies, tagged uk-universities. No discovery or brainstorm — the list is fixed.
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

Scrape published staff directories

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).
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

Verify pending contacts

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.
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

Launch due campaigns

Budgeted, round-robin launches of at most one contact per university per tick. Creates auto_approve campaigns; touch 0 sends inline.
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.
Every stage is fail-closed — a contact whose verify, suppression, or cap check fails is NOT sent.

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

Single egress point

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.
Send

From a touch to a 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.
Compliance

Every message is compliant by construction

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.
Cron

Scheduled sends

CF Email Sending has no server-side scheduling, so a touch queued for later is written to the D1 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

Replies close the loop

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.
Live send/receive logs and error rates for the worker stream into its Cloudflare observability tab (linked from the operator tools below).

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 future wake_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.
Stop-on-reply ends the sequence the moment an inbound arrives — no further touches are sent.
Note: cron triggers only register on wrangler deploy — the worker must be redeployed for any schedule change to take effect.

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.
A global kill switch can halt every LLM path, including this flow, in one step.