How the marketing-agent method works, what we built from it, how to run it, and what it costs. Written from a 44-minute masterclass by Cody Schneider, then from actually building the thing and finding out where it bends.
The method as filmed stops once you have a qualified audience. That turned out to be the loudest criticism of it — and the fairest. Sections 4, 5 and 6 are new: the campaign layer that acts on the list, the lead capture that lets any of it be judged, and the tooling that builds a knowledge base for a trade nobody has covered. Section 10 splits everything still missing into what is a week of work and what is genuinely hard.
Target what someone just showed interest in, not what industry they happen to work in.
Cold email is collapsing because AI-generated volume has flooded every inbox. Sending more into that is not a strategy. The way through is to only contact people who have just raised their hand.
On LinkedIn, a like or a comment is that hand raise. It says a person cares about a subject right now. Firmographics tell you who someone is; engagement tells you what they currently want. So: watch the accounts your customers read, collect the people who engage, qualify them, find their contact details, and write about the thing they just engaged with.
Do not pay a model to perform each step. Pay it once to help you write the software, then run that software on cheap compute. Per-action tokens are a tax on every activity you automate. This is Cody's own phrasing and it is the difference between a system a small builder can afford and one only an enterprise can.
In our build the model is called in three places, and never once per lead in the pipeline stages:
| Stage | Inference | Why |
|---|---|---|
| Source, dedupe | no | API calls and a set |
| ICP gate, stage 1 | no | Job titles decide most cases outright |
| ICP gate, stage 2 | yes | Only the genuinely ambiguous remainder |
| Enrich, verify | no | Ordered API calls, pass/fail grading |
| Campaign layer — angles, creative, spend | no | Deterministic, and deliberately so — see section 4 |
| Campaign copy | yes | Once per campaign; language is the job |
| Reply intent | yes | Once per reply; reading a human |
Measured on 962 real people, stage 1 resolved 333 with no model call at all. That saving compounds every single day the system runs.
10–20 accounts whose audience is your customer. Company pages count as much as people — most teams collect only individuals and halve their coverage. Pick them by hand. Cody deliberately does not automate this, and neither do we: a business already knows who its customers follow.
We seeded ours from the knowledge bases, taking each company's LinkedIn URL from its own website rather than guessing it from the name. Guessing produces a page that looks right and silently tracks the wrong firm.
Screen the accounts before you track them. We did not, and paid for it — see the employer-affinity finding in section 9.
Daily: find each tracked page's new posts, pull everyone who reacted or commented, dedupe on the public profile URL. One Apify actor run per company — the company-posts actor scrapes reactions inline, so this is one run rather than one per post.
Enrichment costs money per lookup. Qualifying afterwards means paying to find contact details for people you were never going to write to. Qualifying first is the quality gate and the cost control in one step. It is the detail most people skim past.
Our gate asks two questions rather than one:
Both true is a yes. Role but unclear domain is the ambiguous middle, and that is where stage 2 earns its cost. Domain vocabulary lives in each campaign's config, so magnetics and home building bring their own without touching the classifier.
Current state on the full harvest: home building 135 yes / 522 maybe / 385 no out of 1,042; magnetics 72 / 665 / 225 out of 962. The large middle column is the gate refusing to qualify on role alone. Emptying it is what stage 2 is for.
Providers in ascending cost order, only the misses falling through: GitLeads → Apollo → Origami or Prospeo, with LeadMagic for mobile numbers. Roughly an 80% find rate is realistic.
Every enrichment key was checked on the machine on 13 August and every one is unset. Today the pipeline reaches a LinkedIn profile and stops there. Saying otherwise — which we did, once, in a meeting — is the easiest overstatement to make about this system.
Every address through Million Verifier. Enrichment output is not clean enough to send to, and bounce rate is the fastest way to destroy a sending reputation.
Four domain lanes, kept apart: burner domains for cold, separate for email marketing, separate for transactional, and the real business domain which never sends cold. Ten thousand cold sends from a live domain ruins it — and at our scale that damage would land across the whole network.
The sending platform fires a webhook on reply. Classify intent, draft toward a booking link, and connect the calendar back so the system sees whether a meeting actually happened rather than assuming.
“A list of interested people is not automated marketing.” Tim Costello, thirty years in home building, on the 13 August huddle. His argument: Google, Facebook and LinkedIn all let any business target people interested in a subject, natively, inside their own ad tools. So finding the audience is step one of ten, and nobody pays for step one. He was right, and the other nine steps are this section.
The campaign layer lives in /opt/autoengine/campaign.py, beside the engine rather
than inside it, and runs on first activation and on every daily run. It is entirely
deterministic — no model calls at all. That is deliberate: a business owner has to be able
to ask why a proposal says what it says and get the same answer tomorrow.
Seven candidate propositions — local, speed and guarantee, proof, expertise, price, range, risk-reversal — each declaring what the site must carry before the claim may be made. A speed claim needs the site to already promise speed. A proof claim needs named customers or review data. An angle with no support is not written. A site with no signals at all yields exactly one angle, not seven invented ones.
In the shape Tim described: “I want to spend $250 in Google to find out the acquisition cost” → comes back with “it is $32 a lead” → the owner decides. Every proposal names the channel, the amount, the duration, the question it answers, and the rule that stops it. A proposal without a kill condition is a bill.
A cost-per-lead cap and a monthly ceiling, exactly as Tim put it: “I will pay $5 a lead, and do not bill me more than $1,000 a month.” An approval that would breach the ceiling is refused with the arithmetic shown. A measured cost per lead above the cap returns a kill verdict rather than a suggestion.
Seven free channels are tracked: on-page fixes, Google Business Profile,
organic content, organic social, trade directories, the existing list, and direct outreach. While
any is outstanding, propose_experiments() returns nothing and gives free-first
as the reason. Paid rents attention and stops the moment you stop paying; the free channels accrue.
Running paid first is how a small budget disappears with nothing left behind.
Dawn Jordan arrived at the same rule from the other direction on her first day of testing: “assume we have five dollars and we are starving — how do we get a customer? Ask for ten thousand and someone will suggest a billboard.” So free is the default posture, and staying there is presented as a legitimate outcome rather than a trial.
Also Dawn's: asking a non-marketer to choose goals and channels is asking them the question they
hired you to answer. POST /recommend reads the live audit and answers it — goals
and channels already chosen, each carrying the signal it came from. On a real magnetics site it
returns “Direct sales, because the site lists 38 products” and “Be
findable at all, because there are only 229 words on it.” Everything stays editable: a
recommendation you cannot interrogate is a default wearing confidence.
Twenty assertions cover the guardrails — free-first refuses, the ceiling refuses, the cap judges, a channel with no leads reports no number, and a site with no signals invents no angle. All pass. The layer was then exercised against 259 real workspaces with zero exceptions, because a generator that only works on the site it was developed against breaks 189 others at 6am.
Everything in section 4 — the cap, the kill rule, the cost per lead — enforces against a number that nothing was producing. Until a lead can be counted, a reported cost per lead is a division with an invented numerator.
/opt/wt-leads on port 8956, proxied at /l/ on the client's own
domain — so no CORS, no third-party script on a site we manage for someone else, and nothing
for an ad blocker to object to.
| What it is | Why it matters | |
|---|---|---|
| Event | Somebody tapped the phone number, the email link or the map link | For a trade this is almost the whole story. The lead is a phone call at 7am; we cannot see the call, but we can see the tap that started it — and attribute it. |
| Lead | Somebody typed their details and asked to be called back | Rarer, and it creates an obligation. A form nobody answers is worse for the client than no form at all. |
Some fraction of taps never become calls. Reporting taps as leads makes
every cost per lead look better than it is, and that is the single easiest lie a system like this
can tell. They are carried as separate numbers with separate words. Likewise
observed() returns null, not zero, when nothing is capturing — “no
instrumentation” and “no leads” are opposite findings.
Attribution is captured on arrival — utm_source, medium, campaign and
content, falling back to the referrer host — and held in sessionStorage for the
visit. No cookie, no fingerprint, no identity. The IP is hashed server-side purely to rate-limit.
What is stored is: something was tapped, at a time, by someone who arrived from somewhere.
Verified end to end in a browser on 13 August: a tap on the phone number arrived attributed to
google / cpc / wells-test / local-angle. Test rows were then deleted so the real count
starts clean.
A client turned up in a trade we had never covered. The engine did not say so — it borrowed a neighbouring industry's knowledge base and wrote confidently about the wrong field. This is the tooling that means it never has to.
kb_from_maps.py and kb_from_linkedin.py each turn a trade name into a
knowledge-base seed. Which one you reach for is not a preference, it is a fact about where that
industry exists:
Maps and LinkedIn both tell you who exists. Neither tells you what the trade talks about. That turned out to be sitting unused in data we already had: every engagement row carries the URL of the post that drew it, and a LinkedIn post URL encodes the author's own hashtags.
topics_from_engagement.py reads them and splits the result three ways:
| Bucket | What it is | Where it goes |
|---|---|---|
| Vocabulary | The trade labelling its own posts | Into the knowledge base |
| Repost candidates | One-off news — an opening, a hire, an award | Listed for a human to judge. Never treated as a topic. |
| Employer affinity | #ilovelennar, #webuildpeopletoo, calendar filler | Excluded, and the proportion is reported |
Magnetics gained fourteen topics this way — Rare Earths weighted 197, Critical Minerals 117, Magnetic Separation, Tramp Metal, Heavy Rare Earths. PolyMagnetics now briefs “The Complete Guide to Rare Earths” instead of something generic.
When no knowledge base matches a trade, topics_for() takes
nothing from the substitute. Another industry's topics are not this client's subject, and writing
articles from them is exactly the failure this tooling exists to prevent.
# a full dry pass -- touches nothing, proves the wiring
cd /opt/wt-outbound
python3 run.py --domain magnetics.wholereach.com --dry
# harvest engagers across every tracked company page
python3 /root/harvest_all.py # magnetics
python3 /root/harvest_builder.py # home building
# qualify by role and domain (breadth ranking is kept, but see section 9)
python3 /opt/wt-outbound/icp_gate.py
# pull the industry's own vocabulary out of the harvest
python3 topics_from_engagement.py magnetics --harvest harvest-magnetics.json
python3 topics_from_engagement.py magnetics --harvest harvest-magnetics.json --write
python3 /opt/autoengine/kb/gather-kb.py magnetics
# build a knowledge base for a trade we have never covered
python3 kb_from_maps.py waterwells --location "Texas" --write
# give already-activated sites the campaign slate (idempotent)
python3 /opt/autoengine/backfill_campaign.py royallswindmill.com --write
# the guardrails -- run these after any change
python3 test_pipeline.py # pipeline
python3 /tmp/test_spend.py # campaign caps, free-first, cost per lead
It runs itself daily at 06:25, before the huddle, via /root/daily-harvest.sh.
Yesterday's results are moved aside so new engagement is seen, and fourteen days of snapshots are kept.
| Stage | State | Needs |
|---|---|---|
| Source accounts | done | 43 magnetics + 60 builders |
| Apify harvest | live, capped | free tier exhausted — $39/mo tier |
| Dedupe | done | — |
| Breadth ranking | kept, distrusted | near-worthless in a trade — section 9 |
| ICP gate, stage 1 | done | — |
| ICP gate, stage 2 | blocked | a model API key |
| Enrichment waterfall | written, dry | Origami + Million Verifier, ~$85/mo |
| Verification | written, dry | Million Verifier account |
| Campaign layer — angles, creative, spend proposals, caps, free-first | done | — |
| Lead capture — taps, forms, attribution | live | one client site; call tracking for real calls |
| Onboarding recommends | done | — |
| KB on demand — Maps + LinkedIn + vocabulary | done | Apify credits per cluster |
| Campaign copy | blocked | a model API key |
| Placing paid ads | not started | Google Ads API — about a month |
| Sending | blocked | domains, inboxes, platform — ~$200/mo |
| Reply handling | blocked | sending platform + model key |
| Organic engine | next | model key for drafting |
Every unbuilt adapter is written and dry — it does nothing without its key and works the moment one exists. Nothing spends money by accident: an adapter goes live only when its key is present and dry-run has been explicitly disabled. Two conditions, not one.
The first LinkedIn actor we tried — the publisher named in the video — was a profile scraper. Handed a company page it did not error; it returned Satya Nadella's posts. Plausible data for the wrong entity.
Every harvest now checks that the source post URL actually contains the company we asked for. Rows that fail are discarded and flagged. Without it we would have built a “magnetics people list” out of a Microsoft executive's audience and had no reason to doubt it.
A water-well drilling client was shown a competitive landscape of Clearscope, Surfer and Frase — SEO software companies. Nothing was fabricated: every company real, every description accurate, the whole thing about the wrong field. That is harder to catch than invention, because nothing looks wrong.
Two causes, compounding. No water-well knowledge base existed. And the engine had read the site's niche as “Royall business” — the company's own name, not its trade — so no keyword could ever have matched.
growth_context() computed a _kb_fallback flag
when it substituted a neighbouring industry, and then discarded it. The flag is now returned, the
onboarding card warns in plain language when comparisons are borrowed, and a borrowed knowledge base
contributes no article topics at all. A system that cannot report its own uncertainty will present a
guess as a finding every time.
We first ranked people by how many different companies they engaged with, on the logic that following several proves industry membership. It surfaced sales staff. We almost concluded the method does not work for our field.
The sort was wrong, not the method — and on the full harvest the size of the error is now measurable. Of the people who genuinely qualify, 86% in home building and 90% in magnetics engaged with exactly one company.
The top of the breadth ranking is vendors — a signing service with 85 followers at eleven companies, a Key Accountmanager, a Customer Relationship Manager. Ranking by breadth pushes nearly every real prospect down. Our read is that this is a software-versus-trade split: a software buyer follows many vendors while evaluating, a plant manager follows the one he already deals with. In a big market breadth may work. In a trade, title works.
55% of home-builder engagement is employer affinity — #ilovelennar, #thisistmliving, #webuildpeopletoo, national intern day. Those pages are recruiting channels, not trade publications. It also explains why the qualified builder list filled with sales and HR staff rather than buyers.
Magnetics runs the other way: 64% genuine subject matter. Worth measuring before pointing the audience engine at any consumer-facing vertical — and worth screening source accounts on.
Of 56 water-well businesses found in Texas, 31 have no working website — 23 list none at all and 8 list one that does not load. That is 55%, and it is not a gap in the data, it is the state of the trade.
It is also the clearest sales argument the engine has: a business with no site cannot be marketed to by any competitor of ours either, because every marketing tool on the market starts by asking for a URL. The full finding, with the prospect list.
Searching it for well drilling returned Tenaris, Schlumberger and 52 other oil-and-gas multinationals. A two-truck outfit in Luling has no company page there at all — the population we wanted is simply not in that corpus, and no filter fixes a missing population.
A $250 pilot at $32 a lead produces eight leads. You cannot distinguish two creatives from eight leads; telling a 3% conversion rate from a 5% one needs hundreds of conversions. So “test 25 creatives and tell me which works” requires either a much larger budget or a much longer run.
No amount of building fixes this. What a $250 pilot honestly delivers is an approximate cost of acquisition and the ability to kill anything obviously terrible. Promising the first and delivering it beats promising the second.
Our first working actor returned nothing because we sent urls where it expected
targetUrls. Reading each actor's published input schema also revealed
scrapeReactions — which halves the number of runs. Guessing cost an hour; reading
saved the budget.
The vocabulary extractor splits run-together hashtags back into words using the knowledge base as
its dictionary. An earlier run had written raw tags into that same knowledge base as topic names
— so rareearths became a dictionary word, matched whole, and never split. It now
reads only from prose and ignores anything beginning with a hash.
| Missing | Effort | What it needs |
|---|---|---|
| Finding email addresses and phone numbers | half a day | Origami + Million Verifier, ~$85/mo. The waterfall is written and dry. |
| Campaign copy, reply handling, deeper qualification | half a day | one model credential |
| A website for a business that has none | 2–3 days | We do it in ten minutes by hand. Templating it roughly doubles the addressable market. |
| Finishing a Google Business Profile | 2–3 days | Highest free return for a local trade |
| Vocabulary for the remaining knowledge bases | 2–3 days | Apify credits, not engineering |
| Item | Cost | Status |
|---|---|---|
| Apify harvesting | $39/mo tier | free tier exhausted — it died partway through the builder harvest |
| Origami (enrichment) | per lookup | not purchased — costs nothing until used, which is why it is first |
| Million Verifier | ~$37/mo | not purchased — buy the same day as any enrichment |
| Inboxes and burner domains | ~$100/mo | not purchased |
| Sending platform | ~$97/mo | not purchased |
| Model calls | 3 points only, never per lead | no key yet |
$39 a month is what is actually being spent today, across the whole network. Every deferred tool together would add about $410. The engine itself costs nothing per client to run — no model calls in the routine path — which is the single largest cost control in the design and the reason a $140 tier is possible at all.
The full ledger, with what each tool has to return to earn its place. Key state there is read from the machine at build time rather than from memory.
The other half of the method turns real conversations into published content. Calls, interviews, transcripts, lost deals — the source material becomes posts.
Never ask a model to “write good content.” With nothing to work from it produces the average of everything it has read, which is precisely what platforms now detect. Every piece must start from something a human actually said.
For this network it runs on topic pages rather than personal brands — accounts named for a subject, not a person. That fits our institutional-byline rule exactly, and the evidence says it works at least as well anyway.
The standing question on the huddle page every morning is this engine's intake: one question a day, rotating, because the knowledge that matters keeps arriving.
| What | Where |
|---|---|
| Pipeline | /opt/wt-outbound/ |
| Campaigns | /opt/wt-outbound/campaigns/*.json |
| Adapters | /opt/wt-outbound/adapters/ |
| Vocabulary extractor | /opt/wt-outbound/topics_from_engagement.py |
| KB builders | /opt/wt-outbound/kb_from_maps.py, kb_from_linkedin.py |
| Campaign layer | /opt/autoengine/campaign.py |
| Lead capture | /opt/wt-leads/ — port 8956, proxied at /l/ |
| Knowledge bases | /opt/autoengine/kb/ — seeds in kb/config/ |
| Ship snapshots | /opt/autoengine/ship-backups/<domain>/ |
| Guardrail tests | /opt/wt-outbound/test_pipeline.py |
| Source notes | /opt/wt-outbound/source/masterclass-260805.md |
| Daily cron | /root/daily-harvest.sh — 06:25 |
| Skills | /opt/wt-marketing-skills/skills/ |
| The method as an app | mktgagent.wholereach.com |
| What the stack costs | wholereach.com/stack |
| The trades finding | wholereach.com/vertical |
| Magnetics worksheet | wholereach.com/contactsmag |
| Company index | wholereach.com/huddle/contacts |
| Daily huddle | wholereach.com/huddle |