# Kestrel GTM: complete published content > Kestrel GTM is a B2B outbound agency. We map a company's entire market, put it in the right order, and work it across email and LinkedIn. Anyone who replies with interest gets on a call with the founder. Canonical facts: https://kestrelgtm.com/llm-info/ ## How it works - Week 1, Your market, mapped: Who buys, why they buy, and the exact accounts worth reaching. Locked with you before anything runs. Client: One kickoff call. - Weeks 1-2, Infrastructure, warming: Secondary domains and mailboxes, set up and warmed in your name. Your main domain never sends a thing. Client: Nothing. - Weeks 2-3, Lists and copy: Every reachable account sourced, verified, enriched and ranked by signal. Angles and sequences written per segment. Client: Approve the list and the copy. - Week 3, First sends: Email and LinkedIn go live, worked in signal order: the accounts most likely to buy right now go first. Client: Nothing. - Every week, Replies and calls: Interested replies reach you the same day. One clear weekly report on what moved, plus tests that sharpen the motion. Client: Take the calls. ## Pricing - GTM Audit: €1,500 fixed. Two weeks. The do-it-yourself blueprint. Everything you need to run outbound in-house. - Outbound Setup: €4,500 per month. 3-month retainer. Done-for-you outbound, built and run in your name. - Custom: from €8,500 per month. 3-month retainer. Custom systems built around your GTM. Two systems shipped a month. - Tooling runs in the client's accounts, on their keys. Budget €600-1,200 a month, paid to the vendors directly. - Forward Deployed: €15,000 per month. An embedded seat, around four days a week. Two engagements a year. - Retainers are billed month by month with a three-month minimum to start, no lock-in after that. ## Stack (last updated 2026-09-24) Build: - Claude Code: Builds every system, on Opus 5.5. - Conductor: Runs coding agents side by side. - GitHub: Code and pull requests. - TypeScript: The code. - Trigger.dev: Scheduled and long-running jobs. - Neon: Postgres. Our own company database. Data: - Clay: Enrichment tables and one-off lists. - Deepline: Access to data providers. - Crustdata: Headcount and funding to qualify companies. - Icypeas: Company and people search. - treg: Email finding and verification. - context.dev: Reading company websites. - Jev: Qualifying companies and scoring replies. - BuiltWith: Companies by the tech they run. - Hunter: Finding companies by description. - DiscoLike: Lookalike companies. - Apollo: People and company search. - Exa: Semantic web search. - Serper: Google search and Maps. - Apify: Scraping LinkedIn and the web. Outreach: - Instantly: Cold email sending and warmup. - Salesforge: Email sequences and one inbox for replies. - HeyReach: LinkedIn outreach. - Google Workspace: Sending inboxes. - Microsoft 365: Sending inboxes. Site: - Cal.com: Booking calls. - Umami: Site analytics. - Dealfront: Identifying companies that visit the site. Work: - Granola: Meeting notes. - Linear: Tasks. - Slack: Client communication. ## FAQ - Q: How quickly can you launch a campaign? A: Count about two weeks from signing to your first campaign going live. The first weeks build your infrastructure: secondary domains and mailboxes, set up and warmed. While they warm, we build your lists and draft your email and LinkedIn sequences. First messages go out around week three. - Q: Do you guarantee meetings? A: We guarantee coverage and hand you every interested reply the same day. - Q: How do you protect my domain reputation? A: We never send from your primary domain. If you do that today, stop, it can wreck your reputation. We set up secondary domains that redirect to yours, each with its own mailboxes. Whatever happens to those, your main domain stays safe, and the spread of domains lets us scale volume without burning anything. - Q: What visibility do I have on the messages and targeting? A: Full visibility, before anything sends. You review the copy drafts and nothing goes out that you are not happy with. You also get the prospect list before we contact anyone. If the targeting is off, we revise the lists until it fits. - Q: What do you report on? A: You get a login to the sending tool with every metric that matters: leads contacted, emails sent, open rate, positive reply rate. - Q: How does pricing work, and do I have to commit long-term? A: A monthly retainer, billed month by month. We ask for a three-month minimum to start, because that is the least it takes to see real results come in from the campaigns. Anything shorter is not enough signal to judge it fairly. After that there is no lock-in. The goal is to earn your business every month with a service you are happy to keep paying for. - Q: Who owns the tools and the data? A: You do. Everything runs in your accounts on your keys. If we stop, the domains, the data and the campaigns stay with you. ## Case study: ClimateCamp Source: https://kestrelgtm.com/case-studies/climatecamp/ How ClimateCamp went from researching every account by hand to 3x the qualified meetings. Client: ClimateCamp sells carbon-accounting software. They already had a growth loop that fed them the right companies to go after, so sourcing was never the problem. The bottleneck was everything that came after: the research on each account before anyone could reach out. Challenge: Every account needed heavy manual research before anyone could reach out, and there was never enough time for it. What we built: - Automate the research: When the growth loop surfaced an account, the system did the account research for it: who to contact, the buying committee and the likely champion, pulled together without a rep doing it by hand. - Personalize on maturity: The core signal was how far along each company was in calculating its own carbon footprint. Messaging was written to the maturity of that calculation, so the hook matched exactly where the company stood, never a generic template. - Draft straight into HubSpot: The research and the copy landed automatically in HubSpot: the relevant hook, the personalized sequence, sharper copy with a real sense of urgency. A rep could send in two minutes instead of researching for thirty. - LinkedIn, then email, then phone: We opened on LinkedIn to warm the account, moved to email when LinkedIn stalled, and used the phone where it made sense. One ordered motion across channels instead of one-off blasts. Results: Qualified meetings roughly tripled. With the sequences automated, meetings started booking off the back of the outreach on their own, and 250+ opportunities came out of it. - 3× qualified meetings - 250+ opportunities - 2 min per send, down from 30 ## Case study: Tightrope Source: https://kestrelgtm.com/case-studies/tightrope/ How Tightrope went from no outbound to an engine the founder runs herself. Client: Tightrope is a seed-stage SF startup, backed by Amplify, moving from developer self-serve to sales-led. The engagement was scoped at three months and designed to hand off: the goal was an engine the founder owns, not a retainer. Challenge: They had no outbound. No lists, no sequences, tracking that lived in a spreadsheet. Every deal so far had come inbound, and there was no repeatable way to go get the next one. What we built: - Build the TAM: We built their total addressable market from scratch: 41,000 principals across 8,600 firms, sourced, normalized, deduped, then segmented and tiered so the best-fit accounts surfaced first. It lives in their own database and CRM. - Stand up the stack: On top of that we stood up the full outbound stack across email and LinkedIn, from data to domains to sequences, all in Tightrope's own accounts. First campaign was live within days of signing. - Test then scale: We ran campaigns in tight batches, proving what worked at low volume before scaling it, so spend followed signal. One in four replies turned into a meeting, including an EVP at a Fortune 500 real estate firm. Results: Pipeline was booking by week 6, including an EVP at a Fortune 500 real estate firm. Three months in, Tightrope had a 2.5% reply rate, a 41k-contact TAM it owns, and an engine the founder has kept running herself: 23 campaigns launched since we handed off. - 2.5% reply rate - 1 in 4 replies became a meeting - 41k contact TAM, owned > "Kestrel did real, thoughtful work. They got our whole outbound stack stood up, ran the early campaigns, and I found them to be really quick with iterating. I learned a lot working with them." (Tara Pichumani, Founder & CEO, Tightrope) ## Case study: Skribby Source: https://kestrelgtm.com/case-studies/skribby/ How Skribby went from zero to 700 users, all product-led. Client: Skribby is a meeting-bot API we co-founded and owned the go-to-market and the systems around, not the product. Challenge: A developer API with no distribution. No brand, no audience, no budget for paid. Growth had to come from systems that ran themselves, not a sales team. What we built: - Content engine: We built automated SEO and blog content that pulled in organic traffic on the terms developers actually searched. - Community listening: Community systems scanned Reddit and X for relevant threads and drafted replies to the right posts, so we showed up where the audience already was. - Activate on signup: PostHog watched what each new user did and triggered nurture and activation flows automatically, turning signups into active users. Results: It went from zero to around 700 users, all product-led and inbound, with no cold outreach. - 700 users, from zero - 100% inbound - €0 ad spend ## Build: Detect 163 B2B tools from a company's website and DNS Source: https://kestrelgtm.com/builds/detect-company-tech/ Category: Public data Built for: Kestrel's own data (anonymized client) Works if: Your product replaces, integrates with or sells to users of specific tools. A crawler that reads company websites, DNS records and subdomains and matches them against hand-verified fingerprints: visitor identification, CRM, marketing automation, hiring tools and more. The free, self-built version of paid technographic data, run on Belgian companies. The signal: A company runs a specific tool, visible in its website code, DNS records or subdomains. Why it predicts a purchase: The tools a company pays for show how it sells. A visitor-identification pixel points to an outbound motion, an Ashby careers page to an early-stage company that is hiring, a Salesforce subdomain to a real sales org. How it works: - Start from a company universe: The free People Data Labs company dataset, filtered to Belgian companies that have a domain, loaded into DuckDB. - Cheap fetch first, render only when needed: Every homepage is fetched over plain HTTP. Only pages that load a tag manager but show no high-signal tool get a full Playwright render, because that is where injected pixels hide. - Read DNS and subdomains: TXT, SPF, DKIM and DMARC records and subdomain CNAMEs catch the tools that have no pixel. - Score the stack: Tier A: two or more high-signal tools, or one plus a software industry. B: one high-signal tool, or a tool plus a software industry. C: any tool. D: none. Build notes: - RB2B's beacon loads from a shared S3 path, not from rb2b.com. - Common Room's beacon is cr-relay.com, not commonroom.io. - Lemlist's pixel is white-labeled Snitcher. - Attio has no web pixel. The TXT verification record is the only tell. Run it in your market: - Pick the few tools that mean your buyer is ready and detect those first. - Combine a trigger with a gap: a visitor-identification pixel with no CRM is a sharper reason to reach out than either alone. Questions: - Q: Which tools can't be detected? A: Cold email tools that send through the customer's own mailbox (Outreach, Salesloft, Apollo, Instantly, Lemlist) leave no DNS trace; only their website pixels show. Call recorders like Gong leave nothing public. Server-side installs leave no pixel at all. - Q: Why check DNS and subdomains as well as the website? A: Some tools have no website pixel. Attio shows up only as a TXT verification record, email platforms in SPF, DKIM and DMARC records, and hiring and help-centre tools as subdomains pointing to the vendor. All detectable tools (tool | category | layer | how to spot it): - FCR Media | Agency-built sites | Website | loads fcrmedia.be - Yools | Agency-built sites | Website | loads yools.be - Google Analytics 4 | Analytics | Website | loads googletagmanager.com/gtag/js; loads google-analytics.com; window.gtag; cookie _ga_ - Hotjar | Analytics | Website | loads static.hotjar.com; loads script.hotjar.com; loads hotjar.io; window.hj - Matomo | Analytics | Website | loads matomo.cloud; loads matomo.js; window._paq; window.Matomo - Ashby | ATS / careers | Website | loads ashbyhq.com - Ashby | ATS / careers | Subdomain | a subdomain points to ashbyhq.com - Carerix | ATS / careers | Website | loads carerix.com - Connexys | ATS / careers | Website | loads connexys.nl - CVWarehouse | ATS / careers | Website | loads cvwarehouse.com; loads cvw.io - Greenhouse | ATS / careers | Website | loads boards.greenhouse.io; loads boards-api.greenhouse.io; loads grnhse.io; window.Grnhse - Greenhouse | ATS / careers | Subdomain | a subdomain points to greenhouse.io - Greenhouse (hiring) | ATS / careers | DNS | TXT record contains greenhouse.io - Homerun | ATS / careers | Website | loads homerun.co - Jobtoolz | ATS / careers | Website | loads jobtoolz-assets.imgix.net; loads jobtoolz.com - Lever | ATS / careers | Website | loads jobs.lever.co; loads api.lever.co - Lever | ATS / careers | Subdomain | a subdomain points to lever.co - OTYS | ATS / careers | Website | loads ows.otys.nl; loads .otys.nl - Personio | ATS / careers | Website | loads jobs.personio.de; loads jobs.personio.com - Prato | ATS / careers | Website | loads prato.be - Recruitee | ATS / careers | Website | loads recruitee.com - SmartRecruiters | ATS / careers | Website | loads smartrecruiters.com - Talentfinder | ATS / careers | Website | loads talentfinder.be - Teamtailor | ATS / careers | Website | loads teamtailor.com; loads teamtailor-cdn.com - Workable | ATS / careers | Website | loads workable.com - Workday | ATS / careers | Website | loads myworkdayjobs.com; loads myworkdaysite.com - Workday | ATS / careers | Subdomain | a subdomain points to myworkdayjobs.com - Zuora (billing = is-a-SaaS) | Billing | DNS | TXT record contains zuora.com - mParticle | CDP | Website | loads jssdkcdns.mparticle.com; loads jssdkcdn.mparticle.com; window.mParticle - RudderStack | CDP | Website | loads cdn.rudderlabs.com; loads cdn.rudderstack.com; window.rudderanalytics; cookie rl_anonymous_id - Salesforce Data 360 | CDP | Website | loads cdn.c360a.salesforce.com; window.SalesforceInteractions - Segment | CDP | Website | loads cdn.segment.com; window.analytics; cookie ajs_anonymous_id - Simon Data | CDP | Website | loads simondata.com; window._sd - Tealium | CDP | Website | loads tags.tiqcdn.com; window.utag - Treasure Data | CDP | Website | loads cdn.treasuredata.com; window.Treasure; cookie _td - Axeptio | Consent | Website | loads static.axept.io; loads axeptio.imgix.net; window.axeptioSettings; window._axcb - Complianz | Consent | Website | loads cookiedatabase.org; loads complianz-gpdr; loads complianz-gdpr; window.cmplz; cookie cmplz_policy_id; cookie cmplz_marketing - CookieHub | Consent | Website | loads cdn.cookiehub.eu; loads cookiehub.net; window.cookiehub - OneTrust | Consent | Website | loads cdn.cookielaw.org; loads otSDKStub; window.OneTrust; window.OptanonWrapper; cookie OptanonConsent - Attio | CRM | DNS | TXT record contains attio-domain-verification - HubSpot | CRM | Website | loads js.hs-scripts.com; loads js.hs-analytics.net; loads js.hsforms.net; loads js.usemessages.com; loads hs-banner.com; loads hsforms.com; loads hsadspixel.net; loads hscollectedforms.net; window._hsq; window.hbspt; window.HubSpotConversations; cookie hubspotutk; cookie __hstc - HubSpot (CMS) | CRM | Subdomain | a subdomain points to hubspot.net; a subdomain points to hs-sites.com - Microsoft Dynamics 365 | CRM | Subdomain | a subdomain points to dynamics.com - Pipedrive LeadBooster | CRM | Website | loads leadbooster-chat.pipedrive.com; loads webforms.pipedrive.com; window.LeadBooster - Salesforce | CRM | Subdomain | a subdomain points to my.salesforce.com; a subdomain points to force.com - Salesforce Experience Cloud | CRM | Subdomain | a subdomain points to my.site.com; a subdomain points to live.siteforce.com - Agari | Deliverability | DNS | DMARC record contains agari.com - dmarcian | Deliverability | DNS | DMARC record contains dmarcian.com - Red Sift OnDMARC | Deliverability | DNS | DMARC record contains ondmarc.com - Valimail | Deliverability | DNS | DMARC record contains vali.email - GitBook | Docs | Subdomain | a subdomain points to gitbook.io - Mintlify | Docs | Subdomain | a subdomain points to mintlify - ReadMe | Docs | Subdomain | a subdomain points to readme.io; a subdomain points to readmessl.com - Shopify | Ecommerce | Website | loads cdn.shopify.com; loads shopifysvc.com; window.Shopify - Drip | Ecommerce email | Website | loads tag.getdrip.com; window._dcq; cookie _drip_client - Klaviyo | Ecommerce email | DNS | CNAME kl._domainkey contains sendgrid.net - Mailchimp | Ecommerce email | DNS | TXT record contains mcsv.net - Moosend | Ecommerce email | DNS | TXT record contains spfa.mailendo.com - Smartschool | Education | Website | loads smartschool.be - Brevo | Email marketing | Website | loads sibautomation.com; loads sibforms.com; loads cdn.brevo.com; window.sib - Brevo | Email marketing | DNS | TXT record contains spf.brevo.com; CNAME brevo1._domainkey contains brevo.com - Campaign Monitor | Email marketing | DNS | TXT record contains _spf.createsend.com - Constant Contact | Email marketing | Website | loads static.ctctcdn.com - Constant Contact | Email marketing | DNS | CNAME ctct1._domainkey contains ccsend.com - Emma | Email marketing | DNS | TXT record contains e2ma-verification= - GetResponse | Email marketing | DNS | TXT record contains _spf.getresponse.com - GoHighLevel | Email marketing | Website | loads widgets.leadconnectorhq.com; loads msgsndr.com - Kit (ConvertKit) | Email marketing | Website | loads f.convertkit.com - Kit (ConvertKit) | Email marketing | DNS | CNAME cka._domainkey contains convertkit.com - Klaviyo | Email marketing | Website | loads static.klaviyo.com; loads a.klaviyo.com; loads static-tracking.klaviyo.com; window.klaviyo; window._klOnsite; window._learnq; cookie __kla_id - MailerLite | Email marketing | Website | loads assets.mailerlite.com - MailerLite | Email marketing | DNS | TXT record contains _spf.mlsend.com - Netcore | Email marketing | DNS | TXT record contains netcorecloud.net - RD Station | Email marketing | Website | loads d335luupugsy2.cloudfront.net; window.RDStationForms - SendPulse | Email marketing | DNS | TXT record contains mxsspf.sendpulse.com - Proofpoint | Email security | DNS | DMARC record contains proofpoint.com - Clearbit / Breeze | Enrichment | Website | loads x.clearbitjs.com - Odoo | ERP | Website | loads odoo.com; window.odoo - Amazon SES | Email sending | DNS | TXT record contains amazonses.com - Mailgun | Email sending | DNS | TXT record contains mailgun.org - SendGrid | Email sending | DNS | CNAME s1._domainkey contains sendgrid.net - Atlassian | Firmographic | Subdomain | a subdomain points to atlassian.net - Adobe Fonts | Fonts | Website | loads use.typekit.net; window.Typekit - Combell | Hosting | DNS | NS record contains combell.net; NS record contains combell.eu; MX record contains mailprotect.be - OVHcloud | Hosting | DNS | NS record contains ovh.net; NS record contains anycast.me; MX record contains mail.ovh.net - Auth0 | Identity | Subdomain | a subdomain points to auth0.com - Okta | Identity | Subdomain | a subdomain points to okta.com - 6sense | Intent | Website | loads 6sc.co; window._6si - Bombora | Intent | Website | loads ml314.com - Delivr.ai | Intent | Website | loads delivr.ai/pixel.js - Demandbase | Intent | Website | loads tag.demandbase.com; loads scripts.demandbase.com; loads secure.company-target.com; window.Demandbase - Factors.ai | Intent | Website | loads api.factors.ai; loads app.factors.ai/assets/factors.js; window.factors - N.Rich | Intent | Website | loads j.nrich.ai; window._nrich - Drift | Live chat | Website | loads js.driftt.com; loads js.drift.com; window.drift - Intercom | Live chat | Website | loads widget.intercom.io; loads js.intercomcdn.com; loads api-iam.intercom.io; window.Intercom; window.intercomSettings; cookie intercom-id- - Act-On | Marketing automation | DNS | TXT record contains act-on.net - ActiveCampaign | Marketing automation | Website | loads trackcmp.net; window.vgo; cookie ac_enable_tracking - Customer.io | Marketing automation | Website | loads cdp.customer.io; loads track.customer.io; window._cio; cookie _cioid; cookie _cioanonid - Customer.io | Marketing automation | DNS | TXT record contains customeriomail.com - HubSpot (email) | Marketing automation | DNS | TXT record contains hubspotemail.net - Inflection | Marketing automation | Website | loads tracking.inflection.io - Marketo | Marketing automation | Website | loads munchkin.js; loads .mktoresp.com; loads mktoweb.com; window.Munchkin - Marketo | Marketing automation | DNS | TXT record contains mktomail.com - Oracle Eloqua | Marketing automation | DNS | TXT record contains eloqua.com - Salesforce Marketing Cloud | Marketing automation | Subdomain | a subdomain points to exacttarget.com; a subdomain points to marketingcloudapps.com; a subdomain points to sfmc-marketing.com; a subdomain points to exct.net - Salesforce Pardot | Marketing automation | Website | loads pi.pardot.com; loads pd.pardot.com; window.piAId - Salesforce Pardot | Marketing automation | DNS | TXT record contains pardot.com - SAP Emarsys | Marketing automation | DNS | TXT record contains emarsys - Stripe | Payments | Website | loads js.stripe.com; loads m.stripe.network; window.Stripe; cookie __stripe_mid - Chili Piper | PLG | Website | loads js.chilipiper.com; window.ChiliPiper - Default | PLG | Website | loads pixel-cdn.default.com; loads import-cdn.default.com - MadKudu | PLG | Website | loads cdn.madkudu.com - Navattic | PLG | Website | loads js.navattic.com; loads capture.navattic.com; window.navattic - Pathfactory | PLG | Website | loads cdn.pathfactory.com - Qualified | PLG | Website | loads js.qualified.com; window.qualified - Reo.dev | PLG | Website | loads api.reo.dev - WonderPush | Push notifications | Website | loads cdn.by.wonderpush.com; window.WonderPush - Zabun | Real estate | Website | loads zabun.be - The Feedback Company | Reviews | Website | loads feedbackcompany.com; loads feedbackcompany.nl - Dock | Sales enablement | Subdomain | a subdomain points to dock.us - Highspot | Sales enablement | Subdomain | a subdomain points to highspot.com - Mindtickle | Sales enablement | Subdomain | a subdomain points to mindtickle.com - Seismic | Sales enablement | Subdomain | a subdomain points to seismic.com - Showpad | Sales enablement | Subdomain | a subdomain points to showpad.biz - KnowBe4 (has IT budget) | Security | DNS | TXT record contains knowbe4.com - Instatus | Status | Subdomain | a subdomain points to instatus.com - Statuspage | Status | Subdomain | a subdomain points to stspg-customer.com; a subdomain points to statuspage.io - Intercom | Support | Subdomain | a subdomain points to intercom.help - Zendesk | Support | Subdomain | a subdomain points to zendesk.com - Zendesk (support) | Support | DNS | TXT record contains mail.zendesk.com - Albacross | Visitor identification | Website | loads serve.albacross.com; loads n.albacross.com; window.Albacross; window._nQc; cookie nQ_visitId; cookie AlbacrossOrg - Apollo (website tracker) | Visitor identification | Website | loads assets.apollo.io/micro/website-tracker - Artisan | Visitor identification | Website | loads artisan.co - Clay Web Intent | Visitor identification | Website | loads claydar.com; window.Claydar - Common Room | Visitor identification | Website | loads cr-relay.com - Datashopper | Visitor identification | Website | loads datashopper.com - Driveniq | Visitor identification | Website | loads driveniq.com - Dun & Bradstreet | Visitor identification | Website | loads d41.co - getuntitled.ai | Visitor identification | Website | loads getuntitled.ai - Happier Leads | Visitor identification | Website | loads revealid.xyz - IdentityMatrix | Visitor identification | Website | loads identitymatrix.ai - Instantly / Leadsy | Visitor identification | Website | loads r2.leadsy.ai - KickFire | Visitor identification | Website | loads kickfire.com - Koala | Visitor identification | Website | loads getkoala.com; window.globalKoalaKey - Lead Forensics | Visitor identification | Website | loads secure.leadforensics.com - Leadfeeder / Dealfront | Visitor identification | Website | loads sc.lfeeder.com; window.ldfdr; cookie _lfa - Leadinfo | Visitor identification | Website | loads cdn.leadinfo.net; loads leadinfo.com; window.leadinfo - LeadLander | Visitor identification | Website | loads leadlander.com - Leadpipe | Visitor identification | Website | loads leadpipe.com - Pearl Diver | Visitor identification | Website | loads pearldiver.io - Propensity | Visitor identification | Website | loads propensity.com - ProspectDesk | Visitor identification | Website | loads prospectdesk.ai - RB2B | Visitor identification | Website | loads b2bjsstore; window.reb2b - Salespanel | Visitor identification | Website | loads salespanel.io - SalesViewer | Visitor identification | Website | loads salesviewer.com; loads svrdntfctn.com; loads slsnlytcs.com; loads slsntllgnc.com - SmartRecognition | Visitor identification | Website | loads smartrecognition.com - Snitcher / Lemlist | Visitor identification | Website | loads cdn.snitcher.com - Swan | Visitor identification | Website | loads getswan.com - Terminus | Visitor identification | Website | loads terminus.services - Vector | Visitor identification | Website | loads api.vector.co; loads vector.co; window.vector - VisitorQueue | Visitor identification | Website | loads visitorqueue.com - Warmly | Visitor identification | Website | loads getwarmly.com; loads opps-widget.getwarmly.com - ZoomInfo WebSights | Visitor identification | Website | loads ws.zoominfo.com/pixel; loads js.zi-scripts.com; window.ZoomInfoFormComplete - Duda | Website builder | Website | loads cdn-website.com; window.dmAPI - Framer | Website builder | Website | loads framerusercontent.com; loads events.framer.com; window.__framer_events - GoDaddy Website Builder | Website builder | Website | loads img.wsimg.com - Jimdo | Website builder | Website | loads jimstatic.com; loads jimcdn.com - JouwWeb | Website builder | Website | loads jwwb.nl; loads jouwweb.be - Squarespace | Website builder | Website | loads squarespace-cdn.com; loads sqspcdn.com; loads static1.squarespace.com; cookie crumb - Webflow | Website builder | Website | loads website-files.com; window.Webflow - WebHero | Website builder | Website | loads webhero.be - Webnode | Website builder | Website | loads clvaw-cdnwnd.com - Weebly | Website builder | Website | loads editmysite.com; loads weeblycloud.com - Wix | Website builder | Website | loads wixstatic.com; loads wixsite.com; loads parastorage.com; window.wixBiSession; cookie svSession - Elfsight | Widgets | Website | loads elfsightcdn.com; loads static.elfsight.com - Trustindex | Widgets | Website | loads cdn.trustindex.io; loads trustindex.io Build prompt (for a coding agent): ``` Build a crawler that detects which B2B tools a list of companies runs, from their website, DNS and subdomains, and tiers each company by its stack. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{COMPANIES}}: companies with a domain (a free company dataset or your CRM export). - {{FINGERPRINTS}}: per tool, what gives it away: script hosts or paths, window globals and cookies (website); TXT, SPF, DKIM or DMARC substrings (DNS); CNAME targets (subdomains). Verify each one on a live site that runs the tool before trusting it. - {{HIGH_SIGNAL}}: the tool categories that mean your buyer is ready. Data model: companies(domain unique, name, industry, size), detections(domain, tool, category, layer, evidence, unique(domain, tool, layer)). Steps: 1. Load {{COMPANIES}} into a local database (DuckDB works well for hundreds of thousands of rows). 2. Fetch every homepage over plain HTTP with a short timeout and match {{FINGERPRINTS}} against the HTML and the script URLs. Record the matched string as evidence. 3. Render with Playwright only the pages that load a tag manager but matched no high-signal tool; match again against request URLs, window globals and cookies. 4. Resolve DNS for every domain: apex TXT (verification tokens and SPF), _dmarc, and the DKIM selectors you care about. Enumerate subdomains and resolve their CNAMEs. 5. Score: A = two or more {{HIGH_SIGNAL}} tools, or one plus a software industry; B = one; C = any tool; D = none. 6. Make the crawl resumable: store crawl state per domain and skip finished ones on re-run. Rules: - Match vendor beacons, not vendor marketing sites: path-qualify hosts that the vendor also uses for its own site. - Store the evidence string for every detection so it can be checked by hand. - Keep the fingerprints in one config file; add a tool only after verifying it live. Done when: every company has a tier, every detection has evidence, and a re-run of the crawl adds only new domains. ``` ## Build: Turn a vertical database into a 3-touch outbound engine Source: https://kestrelgtm.com/builds/tightrope-outbound-system/ Category: Your own data Built for: Tightrope · Seed-stage startup · San Francisco (anonymized client) Works if: A vertical database already lists your buyers with contacts, and a founder will make the closing calls. Earlier campaigns sourced accounting firms and real estate brokers with Clay. For the owners campaign we pulled a licensed real estate operator database into Tightrope's own Postgres and cut it down to one verified owner per firm. Each owner got LinkedIn, then email, then a call from the founder. Sequence data syncs back into the same database, so a scheduled job knows who finished without replying and puts a call task in the CRM. The signal: Owner-level fit from a vertical operator database, then sequence status: a lead who finishes the emails without replying queues a founder call. Why it predicts a purchase: The buyer was the owner-operator: they spend their own capital, so savings land on them, while a third-party manager bills by the hour. Trust was the constraint, so every owner got the full sequence instead of a cold-email-only tier. How it works: - Mirror the database: We pulled the full operator database through its API into Postgres, in its own schema. It has no record IDs and different people share emails, so the dedup key is a hash of the whole raw row. The 60k listed contacts came down to about 41k real people. - Cut one owner per firm: Claude subagents classified every job title, because a regex let vice presidents in through the word 'president'. The cohort kept owner titles with an email, a rental theme and a unit band, one owner per firm, firms with a LinkedIn page ranked by portfolio size. Only emails that passed two verifiers went out: 685. - Run LinkedIn, then email: Contacts, companies and phone numbers went into HubSpot. HeyReach sends a blank connection request and up to three chats, then hands leads to Instantly for a value email and a bump. The handoff drops custom fields, so a scheduled job patches the asset type into each email lead and the first email waits a day for it. - Close on a call: Every three hours Trigger.dev mirrors Instantly and HeyReach into Postgres. A lead who finished the emails without replying gets a HubSpot call task for the founder, with the firm, asset type and prior touches in the body. A ledger table keeps it to one task per person. Build notes: - The dashboard first read email only and showed the owners campaign as nearly dead, because the campaign ran mostly on LinkedIn. Mirroring HeyReach into the same database fixed the read. - A tile labeled 'Meetings booked' was renamed 'Opportunities'. The number was a manual flag in the sequencer, not a confirmed meeting. - The vendor database never gets bulk-loaded into the core tables. A record is promoted when it enters a campaign, so core stays the worked pipeline. Run it in your market: - If a list source has no record IDs, dedup on a hash of the whole raw row, and diff a sample of 'duplicates' field by field before trusting any natural key. - Mirror each sequencer straight into your own database. Reading channel performance back out of the CRM loses campaign, step and variant. Questions: - Q: Why not run everything out of the CRM? A: The CRM is where the founder works deals and makes calls. Its native integrations with the sequencers flatten campaign, step and variant, so each tool is mirrored into Postgres on its own and the dashboard reads SQL views there. The CRM gets the campaign contacts and the call tasks. - Q: When does the founder pick up the phone? A: After the LinkedIn touches and the email sequence. A call task is only created when a lead finished the emails without replying and did not unsubscribe, and the task lists the touches that already happened so the call opens warm. Data model (tables, columns, foreign keys): - core.companies(id uuid pk, name text, domain text, linkedin_url text, created_at timestamptz, updated_at timestamptz) - core.contacts(id uuid pk, company_id uuid fk, first_name text, last_name text, email text, title text, linkedin_url text) - thesis.gps(id bigint pk, row_hash text, company_id uuid fk, gp_name text, website text, themes text, units_owned text) - thesis.principals(id bigint pk, row_hash text, gp_id bigint fk, contact_id uuid fk, email text, title text, linkedin_url text) - thesis.signals(id bigint pk, row_hash text, company_id uuid fk, gp_name text, signal_type text, article_url text, article_date timestamptz) - hubspot.contacts(hs_object_id text pk, contact_id uuid fk, company_id uuid fk, hs_company_id text, email text, lead_status text, utm_campaign text) - hubspot.deals(hs_object_id text pk, company_id uuid fk, deal_name text, deal_stage text, pipeline text, amount numeric, close_date timestamptz) - instantly.campaigns(id text pk, name text, status int, timestamp_created timestamptz, payload jsonb) - instantly.leads(id text pk, campaign_id text fk, contact_id uuid fk, company_id uuid fk, email text, status text, custom_variables jsonb) - heyreach.campaigns(id bigint pk, name text, status text, total_users int, users_finished int, users_failed int) - heyreach.conversations(id text pk, campaign_id bigint fk, correspondent_profile_url text, last_message_at timestamptz, last_message_sender text, total_messages int, lead_replied boolean) - ops.call_tasks(contact_email text pk, hs_contact_id text, hs_company_id text, campaign_id text, hs_task_id text, status text, created_at timestamptz) Foreign keys: - core.contacts.company_id -> core.companies.id - thesis.gps.company_id -> core.companies.id - thesis.principals.gp_id -> thesis.gps.id - thesis.principals.contact_id -> core.contacts.id - thesis.signals.company_id -> core.companies.id - hubspot.contacts.contact_id -> core.contacts.id - hubspot.contacts.company_id -> core.companies.id - hubspot.deals.company_id -> core.companies.id - instantly.leads.campaign_id -> instantly.campaigns.id - instantly.leads.contact_id -> core.contacts.id - instantly.leads.company_id -> core.companies.id - heyreach.conversations.campaign_id -> heyreach.campaigns.id Build prompt (for a coding agent): ``` Build a multichannel outbound system on top of a licensed vertical database: mirror it, cut one verified buyer per firm, run LinkedIn then email, and queue a founder call for everyone who finished without replying. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, Claude for classification. Inputs: - {{DATABASE_API}}: the vertical database's API (firms, people). - {{BUYER_ROLE}}: the role that feels the pain, e.g. owner or principal. - {{COHORT_FILTERS}}: firm filters, e.g. theme and size band. - {{CRM}}, {{LINKEDIN_TOOL}}, {{EMAIL_TOOL}}: the tools you already use. Data model: - core.companies(id, name, domain unique), core.contacts(id, company_id, email unique on lower(email), title, linkedin_url) - one schema per external tool (source, crm, email, linkedin): external id as primary key, nullable FK to core, payload jsonb, synced_at - ops.call_tasks(contact_email primary key, crm_contact_id, crm_task_id, status) Steps: 1. Pull the full database with pagination and retries. No record IDs: dedup on md5 of the full raw row. Diff sample "duplicates" field by field before trusting any natural key. 2. Link people to firms by name at load. Promote a record into core only when it enters a campaign. 3. Classify every distinct title with Claude into {{BUYER_ROLE}}, exec, champion or out: one JSON line per title keyed by integer index, missing defaults to out. No regex. 4. Cohort: buyer title, has email, {{COHORT_FILTERS}}, one best person per firm, has a LinkedIn URL, ranked by firm size. Run two email verifiers and keep only addresses both mark valid. 5. Upsert contacts and companies into {{CRM}}. Turn off auto-create-company-from-email-domain first. Normalize phones to E.164. 6. Add leads to {{LINKEDIN_TOOL}} with all custom fields on first add, then hand off to {{EMAIL_TOOL}}. If the handoff drops fields, patch them on a schedule and delay email one by a day. 7. Every 3 hours: mirror both tools into Postgres, resolve worked leads to core, then create a CRM call task for each lead who finished the emails with zero replies and did not unsubscribe. ops.call_tasks makes it once per person. List prior touches in the task. 8. Dashboard reads SQL views only: funnel per channel, latest replies. Label sequencer "opportunities" as opportunities, not meetings. Done when: a second run of every job changes nothing, LinkedIn and email show side by side on the dashboard, and a dry run lists exactly the leads that would get a call task. ``` ## Build: Reach companies before their public deadline Source: https://kestrelgtm.com/builds/public-deadlines/ Category: Public data Built for: B2B SaaS · Europe (anonymized client) Works if: Your buyers make public promises with a deadline. A public dashboard lists every company that committed to a public industry target. Committed companies get 24 months to get the target validated. We calculated who is running out of time, and flagged the ones that gave up. The signal: A company committed to a public target and the 24-month validation window is closing, or it withdrew the commitment. Why it predicts a purchase: They already decided this matters and put it in public. A closing deadline creates urgency, and a withdrawn commitment means they tried, hit a wall and still have the pressure. Both are warmer than a company that never started. How it works: - Parse the file the way it is built: One company can have five or more rows, one per target. Group them into one company without losing which target is in which state. - Match like you mean it: Big corporates in a public file rarely match a CRM on name. Legal-suffix stripping and trigram matching, then creating the companies that still didn't match (and enriching them after), took resolution from 26.7% to 99.6%. - Do the date math: Committed plus 24 months is the validation deadline. Anything inside 6 months becomes a signal, bucketed by urgency. Withdrawn commitments get their own flag. Build notes: - The no-code version silently returned zero rows from the Excel file. That single failure is why every pipeline for this client moved to Python and later TypeScript. - Postgres date minus date returns an interval, not a number. Wrap it before you compare it to 180 days. - The context note needs to explain the signal. A note that only says the status and classification assumes the rep already knows what the framework is. Run it in your market: - Look for any public pledge with a clock on it: net-zero targets, accessibility deadlines, compliance frameworks with a grace period, public hiring or DEI pledges. - Treat lapsed or withdrawn commitments as a segment, not as a disqualifier. - Refresh on a schedule. Deadlines move into the window on their own. Questions: - Q: Why target companies that withdrew a commitment? A: They already decided it mattered and spent effort on it, then hit a wall. The pressure did not go away. That makes them warmer than a company that never started. - Q: How do you match companies from a public file to a CRM? A: In tiers: domain, LinkedIn URL, exact name, cleaned name with legal suffixes stripped, trigram match, then create the company if nothing matches. Every match stores its tier. That took resolution from 26.7% to 99.6%, mostly by creating the companies the CRM never had. Build prompt (for a coding agent): ``` Build a pipeline that finds companies with a public commitment and a deadline, and flags who is running out of time. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{DASHBOARD_EXPORT_URL}}: a public file listing commitments, one row per target (e.g. a pledge or target dashboard export). - {{COUNTRY}}: the market to keep. - {{WINDOW_MONTHS}}: how long a company has to deliver after committing (e.g. 24). Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Download and parse the file with a real spreadsheet parser. Rows are per target, not per company: group them by the file's company id into one record, keeping each target's status (committed, targets set, commitment removed) and dates. 2. Filter to {{COUNTRY}}. 3. Resolve each company with the tier rules below. For rows that still do not match, ask Claude to pick the right LinkedIn company page from a web search result and accept only high-confidence answers. Create the company only after that fails, flagged created_from = "public_file". 4. Write a "commitment" signal per company (dedup on file company id + status). 5. Deadline math: committed date + {{WINDOW_MONTHS}}. Emit a "deadline" signal when it is overdue, under 3 months, or 3 to 6 months out. Emit a separate "withdrawn" signal for removed commitments: they tried and stopped, they are warm, not dead. Use integer day differences, not intervals. 6. Run scrape, resolve and deadline as three Trigger.dev tasks. Put them on a cron (weekly is enough) so new deadlines move into the window on their own. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: the match rate is above 95% (report it), every deadline signal has a days_remaining value, and a re-run produces only new deadlines that moved into the window. ``` ## Build: Reach companies hiring for what you sell Source: https://kestrelgtm.com/builds/hiring-for-what-you-sell/ Category: Public data Built for: Software development agency · US (anonymized client) Works if: You sell a service that companies also try to hire for. The client is a development shop built around one stack. A company posting a role in that stack needs exactly that, today. We scraped job posts daily, had AI classify which ones were real needs, researched each company and contact with small agents, and automated the whole path into the sending tool. The same machine later ran a second motion on 5,000+ firms sourced from a directory. The signal: An agency or software company opens a job for the exact skill the client sells. Why it predicts a purchase: It is not intent for a product, it is a direct request for the service, with a budget line already approved. The only question is whether you arrive before they hire. How it works: - Scrape jobs every day: Bench capacity is perishable and so is the job post. A daily cron picks up new postings. - Classify before you count: A keyword match on the stack is noisy. An AI classifier decides whether the role really needs it before it becomes a signal. - Split the research into agents: One agent reads the job, one the company, one finds the contact, one writes. Small agents with one job are easier to fix than one agent doing everything. - Write like a peer: The angle was plain: we have developers between projects. Copy went through five versions, from hyper-personalised to natural. Build notes: - Sequence change: CEO first, then others one by one. - The same machine was repointed at a second motion (agency partnerships), and those campaigns got the most engagement. Europe outperformed the US. Run it in your market: - If you sell a service, the job post for that role is your strongest signal. Search the exact skill, not the category. - Speed wins. Run it daily and send within days of the post. - Offer the thing the job post asks for, in their words, with a start date. Questions: - Q: Why is a job post such a strong signal for services? A: It is a direct request for the thing you sell, with a budget line already approved. The only question is whether you arrive before they hire. - Q: How fast should outreach go out? A: Within days. Run the scrape daily and treat the post as perishable, just like your team's free capacity. Build prompt (for a coding agent): ``` Build a daily pipeline that finds companies posting a job for exactly the service you sell and gets a peer-to-peer email out while the post is fresh. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{SKILL}}: the exact skill you sell (e.g. a framework or role), with the search terms engineers use. - {{REGIONS}}: regions you can actually serve. - {{OFFER}}: what you offer instead of the hire (e.g. developers available now). Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. A Trigger.dev cron runs daily and pulls new job posts for {{SKILL}} in {{REGIONS}}. Dedup on job URL. 2. Classify each post with Claude: is this role really about {{SKILL}}, or is it keyword noise? Keep only real matches, store the reason. 3. Research with small, single-purpose steps: one reads the job (need, seniority, urgency), one the company (size, stack, fit), one finds the contact (CEO or hiring lead first). 4. A copy step writes a short peer-to-peer email: the need from the post in their words, {{OFFER}}, a start date. No personalisation beyond what the post says. 5. Push to a sheet or directly to the sequencer, CEO first, others later one by one. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every new post in the last 24 hours is classified, every kept post has a researched company and contact, and the median time from post to queued email is under 48 hours. ``` ## Build: Find buyers in certification registries Source: https://kestrelgtm.com/builds/certification-registry/ Category: Public data Built for: B2B SaaS · Europe (anonymized client) Works if: Your buyers can hold a certificate or standard that proves they care. A public register of certified companies, with an expiry date on every entry. We scraped it, resolved the companies behind it, found the right contact and put each one in the CRM with a written first email and a context note. The signal: A company holds a certificate, and the certificate has a renewal date. Why it predicts a purchase: Certification costs money and effort. A company on this list has already bought into the category, and the renewal date tells you when they have to prove it again. How it works: - Scrape the list, keep the date: Every certified organisation with its certificate type and validity. Keep the date. It is the part that creates timing. - No ghost companies: A listing only becomes a company when it resolves to a real LinkedIn page or domain. Unmatched names go through a search recovery pass instead of being created blind. 35 of 35 recovered. - Rank contacts by who owns the topic: A four-tier waterfall: the titles that own the topic, then C-level, then adjacent functions, then everyone else. Fixing the title regex and re-searching 13 companies moved weak contacts from 24 to 1. - Hand the rep everything: Each lead lands with a contact, a first email from a fixed template and a context card on the company, so a rep can call or send. Build notes: - Scope grew after the first batch: the client asked for the same context notes on every pipeline because they were great for cold calling. The template became the delivery layer for two other sources. - Tell the prospect where the fact came from, correctly. The copy said the initiative came from their own report. It came from the registry. Fixed across all 79 notes before sending. - HubSpot rejects a standalone lead without an inline association, and the company LinkedIn field is linkedin_company_page, not the one you would guess. Run it in your market: - Find the register of certified companies in your space: ISO, B Corp, SOC 2 trust pages, quality marks, industry charters. - Keep the expiry or renewal date. It is your timing signal for free. - Resolve strictly. A wrong company costs you more than a missing one. Questions: - Q: Where do I find certification registries for my market? A: Most standards bodies publish who is certified: ISO bodies, B Corp, quality marks, industry charters, security trust pages. Search for the standard plus “certified organisations” and check whether the list has a validity date. - Q: Why keep the renewal date? A: It turns a static list into timing. A company 30 to 90 days from renewal is about to prove the certificate again, which is when your product matters most. Build prompt (for a coding agent): ``` Build a pipeline that turns a public certification registry into CRM leads with a contact, a first-email draft and a context note. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{REGISTRY_URL}}: the public list of certified organisations (paginated HTML), e.g. a standards body's "certified organisations" page. - {{TITLES}}: the titles that own the topic, in priority order. - {{TEMPLATE}}: your approved email template. The model only fills its variables. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Scrape every listing: organisation name, certificate type or level, valid-until date, detail URL. Check the page source first; do not assume the markup. 2. Resolve each listing to a company. If there is no domain or LinkedIn match, run a web search for the official site and LinkedIn page. Never create a company from the name alone. 3. Write a signal per company, type "certification", dedup on registry detail URL. Add a derived "renewal" signal with urgency: overdue, under 30 days, 30 to 90 days. 4. Find contacts and score them in four tiers: {{TITLES}} first, then C-level, then marketing, HR and finance, then everyone else. Keep the best one per company. 5. Push to the CRM: company, contact, lead, one context note (certificate, level, renewal date, source link) and one email draft filled from {{TEMPLATE}}. The model fills variables only; it never writes claims. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every listing is either matched to a company or logged as unmatched with a reason, every lead in the CRM has a contact, a note and a draft, and a second run creates zero new rows. ``` ## Build: Build one account list per compliance norm Source: https://kestrelgtm.com/builds/compliance-norm-registers/ Category: Public data Built for: B2B services · Europe (anonymized client) Works if: Your buyers fall under a security norm or law, and public registers show who is in scope. The client helps organisations get audit-ready against several security norms. We scraped 20+ public registers into one account list per norm: a public-sector baseline, a sector standard, an EU regulation and an information security certification. Web-search subagents filled in missing domains and headcounts, a fixed five-signal score ranked the regulation list, and the first contact batch was capped at four people per company. The same base was later reused for a second client's five ICPs. The signal: An organisation is listed in a register that puts it in scope for a security norm. For one certification, it still holds the old version. Why it predicts a purchase: Being in scope means audits or legal duties exist before anyone reaches out. The register that proves it also tells you the sector tier, and for a certification, which version the organisation holds and whether a move to the current one is still ahead. How it works: - One source per norm: Each norm gets the registers that prove who is in scope: a government register of public bodies, a certification register plus sector member lists, regulator lists per sector, and trade association member lists. 20+ sources, 3,515 accounts in the delivered list, every one with a domain. - Keep the norm version: The certification register is one static page that shows the norm version on every certificate. Organisations with only old-version certificates became their own segment: 708 of 1,416. They still have to move to the current version, so the list carries its own timing. - Score with a fixed formula: The regulation list of 1,310 accounts was ranked on five signals already in the data: sector tier from the source register, size, a known security contact, overlap with other norms and match certainty. The score is plain code with no model in it, and every row carries its breakdown next to the total. - Cap contacts per company: Contacts came from the client's own LinkedIn export, kept only where the company was already on the list: 2,150 people at 299 companies, split into a security persona and an IT persona by title. The first top 300 landed on 33 companies. A cap of 4 per company spread it over 105: 192 security and 108 IT contacts. Build notes: - A domain is not a company key. Several public bodies share one domain, and one operator appeared ten times on a regulator list. The national registration number is the identity. 214 duplicate domains were merged and 302 facility rows dropped. - A search-engine scrape for the 1,733 missing domains hit a captcha. 18 web-search subagents with 100 companies each found 93% in about 30 minutes, at no data cost. Headcount was harder to find: 70% of 1,339. - Check the sector tiers against the law text before tuning weights. A research pass against the regulation put one sector a tier higher than assumed, which moved 251 rows into the top sector tier. - Match contacts to companies on LinkedIn URL, then domain, never on name alone. Name matching showed 53% of accounts with a contact. The real number was 23%. Run it in your market: - Look for registers that record which version of a norm or standard an organisation holds. The old version is a segment with a reason attached. - Keep the source register on every account. It tells you the sector tier and becomes a scoring input. - Cap contacts per company before you cut a top N, or a few large accounts take the whole batch. Questions: - Q: Why split accounts by norm version? A: The certification register shows which version of the norm each organisation holds. An organisation still on the old version has to move to the current one, which is a dated reason to reach out. Here that was 708 of 1,416 certified organisations. - Q: Why cap contacts per company? A: Without a cap, the top 300 contacts sat at 33 companies, with one company taking 11 or more slots. A cap of 4 per company spread the same cut over 105 companies. Build prompt (for a coding agent): ``` Build a pipeline that turns public registers into one account list per compliance norm, splits certified organisations by norm version, ranks a list with a fixed score and cuts a first contact batch with a cap per company. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{NORMS}}: the norms or laws you sell into, each with the registers that prove who is in scope (government registers of public bodies, certification registers, regulator lists, member lists). - {{SIZE_THRESHOLDS}}: minimum headcount per norm, where the norm has one. - {{WEIGHTS}}: points per score signal, adding up to 100. - {{CONTACTS_EXPORT}}: a people export (e.g. a LinkedIn search) with name, title, company LinkedIn URL and company website. - {{PERSONAS}}: title patterns per persona, in priority order. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. One scraper per register. Keep the raw row and the source name. Read the page source first: certification registers are often one static page with the whole table in it. 2. Resolve to companies. Use the national company registration number as identity where the source has one. A domain is not unique (public bodies share one, multi-site operators repeat it), so merge multi-site entries into the parent company instead of creating one company per site. 3. Write a "norm" signal per company per norm, with the source register in attributes. The source says which sector tier the company falls in, so keep it. 4. For certification registers, store the norm version per certificate and derive a segment per company: old version only, current version only, or both. 5. Fill missing domains, and headcount only for norms in {{SIZE_THRESHOLDS}}, with parallel web-search subagents of about 100 companies each. Each returns strict JSON with the value and the URL it came from. Spot-check 50 domains by hand: alive, and the right company. 6. Score each account in deterministic code on {{WEIGHTS}}: sector tier from the source register, size band, known security contact (with a bonus if they started in the role recently), overlap with other norms, match certainty. Write the total and a one-line breakdown per row. Check the sector-tier mapping against the text of the law or standard before tuning weights. 7. Load {{CONTACTS_EXPORT}}. Match people to companies on company LinkedIn URL, then domain, never on name alone. Keep only people at companies already on the list. Classify titles into {{PERSONAS}} and log a sample of unclassified titles. 8. Cut the top N: walk companies by score, take at most 4 people per company (highest persona first), stop at N. Report distinct companies, persona split and score range. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every norm has its own list with a source on every account, every certified account has a version segment, every scored row has a breakdown that adds up to its total, and no company holds more than 4 slots in the top N. ``` ## Build: Build account lists from public tender winners Source: https://kestrelgtm.com/builds/public-tender-winners/ Category: Public data Built for: B2B vendor · Europe (anonymized client) Works if: Your buyers sell to the public sector through tenders. The brief was a list of companies in one regulated product category that bid on public tenders. We used the national register of licence holders for that category as the company base, added the local sales subsidiaries it misses, and matched every company against public procurement award data. A company with a won tender became Tier 1, with the award link on the row as proof. Contacts came from LinkedIn search and research subagents, without emails, because the client ran its own email enrichment. The signal: A company won a public contract for the products in your segment. Why it predicts a purchase: An award is public proof that the company already bids and wins in that channel. The record names the public buyer and the date, so anyone can check it before reaching out. How it works: - Start from the register that defines the market: The company base is the national register of licence holders, about 655 companies. Makers of neighbouring products hold no licence, so they never enter the list. Award winners outside the register were around 80% suppliers of those neighbouring products. - Add the companies that sign the contracts: A foreign parent often holds the licence while the local subsidiary wins the tender under its own company ID. A public register of companies active in the category and the national company register by industry code filled that gap. Merged and deduplicated on company ID, the list came to 1,895 companies. - Tier by proof, with a fixed rule: A won tender in the category's award codes or the notices feed makes Tier 1, with the award link attached. Trade body members and purchasing-group suppliers without an award are Tier 2. Licence holders with no award yet are Tier 3, and that is most of the list. Companies that mix licensed and unlicensed products were flagged for review instead of dropped. - Find people, then look them up again: The client ran its own email enrichment, so a contact needed a name, title, LinkedIn URL and company. A Google search restricted to LinkedIn profiles found people per company and persona. Research subagents took the priority companies and caught misclassified and defunct companies. Search contacts were looked up a second time: 159 of the first 166 were confirmed and the other 7 were dropped. Build notes: - One large public buyer does not publish its awards on its own site. They sit in the national notices feed under its central purchasing agency's name, and searching the buyer's full name pulled in a different city's organisations. - The current award dataset carries the supplier's company ID but not its name, and the company lookup API throttled the IP under concurrent calls. An older award dataset ships names already resolved, so it became a local ID-to-name map: 97% resolved, no rate-limited calls. - The award dataset's slice for the category stops at 2023, and the notices feed carries the 2025 and 2026 awards. Each row shows the most recent award across both sources. - Apollo's people search hides names and LinkedIn URLs until you pay to reveal them, and the reveal sells email and phone. For a list of names with LinkedIn URLs, search results were the better source. Run it in your market: - Find where public buyers in your market publish awarded contracts, and filter on the product codes your buyers sell. - Take the company base from a register that defines your segment, and use award data as proof on top of it. - Keep the award link on every Tier 1 row so anyone can check the proof. Questions: - Q: Where does tender award data come from? A: Public buyers publish awarded contracts with the winning supplier and a product classification code. In this build that was a national award dataset and the official notices feed, both on free APIs with no key. Filter on the product codes of your segment. - Q: Why is the tier a fixed rule and not an AI score? A: The tier answers one question: how strong is the proof that the company bids. An award is proof, a trade body membership or a purchasing-group listing is a proxy, and a product licence only means the company is in the market. A fixed rule gives the same answer on every run. Build prompt (for a coding agent): ``` Build a pipeline that turns public tender award data into a tiered account list: every company in your segment, with the ones that already won a public contract ranked first and the award attached as proof. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{SEGMENT_REGISTER}}: the official register that defines your segment (e.g. product licence or authorisation holders). This is the company base. - {{EXTRA_REGISTERS}}: registers that list the local subsidiaries, distributors or manufacturers the base misses (e.g. the national company register filtered by industry code). - {{AWARD_SOURCES}}: where public buyers publish awarded contracts with the winning supplier (a national award dataset, the official notices feed). - {{PRODUCT_CODES}}: the procurement classification (CPV) prefixes for what your buyers sell. - {{PROXIES}}: softer channel evidence, e.g. trade body members or purchasing-group supplier lists. - {{PERSONAS}}: the roles to find, each with local-language and English title keywords. Data model (create what is missing): - companies(id, name, national_id unique, domain, linkedin_url, hq_country, sources text[], tier, status, status_reason) - awards(id, company_id, buyer, award_date, product_code, evidence_url, source, unique(source, evidence_url)) - contacts(id, company_id, name, title, persona, linkedin_url unique, verification) Steps: 1. Load {{SEGMENT_REGISTER}}. Check the file encoding before parsing. One row per holder, tagged with its HQ country. 2. Load {{EXTRA_REGISTERS}} and merge on the national company ID first, then on normalised name. Collapse rows that share a company ID. Keep a sources list per company. 3. Pull awards from {{AWARD_SOURCES}} filtered to {{PRODUCT_CODES}}. Use a prefix match on the code, because codes carry a check-digit suffix. If a dataset gives supplier IDs without names, resolve them locally from another dataset that has both before calling a rate-limited lookup API. 4. Match award winners to companies in the base. Winners that match nothing are mostly outside the segment: log them, do not add them. 5. Tier with a fixed rule, no model. Tier 1 = at least one matched award; store the most recent award across all sources and its URL. Tier 2 = in {{PROXIES}}, no award. Tier 3 = in the base, no award. Companies that also sell out-of-scope product lines get status "review", never a silent drop. 6. Contacts: per company and persona, run a Google search restricted to site:linkedin.com/in with the company name, the country and the title keywords, localised to the market. Take name, title and profile URL from the results. For priority Tier 1 companies, fan out research subagents in batches (write a batch file in, have each agent write a result file out, merge once) and tell them to return nothing rather than guess a URL. 7. Verify every contact with a second, fresh search on name and company. Keep it when the same profile URL comes back; mark it "verified" when the title also matches a persona. Drop the rest. 8. Export two tables, companies and contacts, with the tier, the evidence URL and the verification status on every row. Rules for the whole build: - The tier is a rule on evidence, never a score. Every Tier 1 row has an award URL a person can open. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Cache every external call to disk, misses included, so a rerun only hits new rows. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Never invent a contact or a LinkedIn URL. An empty result is a valid result. Done when: every company has a tier and a sources list, every Tier 1 row has an award URL that resolves, the tier counts are reported, and every contact has a verification status. ``` ## Build: Turn award and member lists into leads Source: https://kestrelgtm.com/builds/member-lists/ Category: Public data Built for: B2B SaaS · Europe (anonymized client) Works if: Federations, chambers or award programs publish your buyers as members or winners. A business federation publishes every company that completed one of its programs, grouped by region. We turned the list into 314 CRM leads with researched context and region set, 218 of them with a contact. The signal: A company completed a federation's charter program. Why it predicts a purchase: Completing a charter program takes structured work and a sign-off from leadership. These companies have an owner for the topic, a budget and a public story they want to keep telling. How it works: - Scale check first: The estimate was around 450 companies. The real list was 320 across 6 regions. Know the size before you plan credits. - People search that does not answer instantly: The contact search API is asynchronous: start a job, poll every 3 seconds, fetch the result. 218 of 314 companies returned a contact in about 2.3 hours of runtime. - Research with a hard rule: Each company got a live web research pass for its real initiatives, prompted in the prospect's language with one instruction above all others: only facts you actually find, invent nothing. - Region as a first-class field: Leads carry region and source as CRM properties, so the best-converting region could be pulled out and worked first. Build notes: - HubSpot has a hard cap on pipelines. The planned dedicated pipeline became custom properties on a shared one. - The first contact provider ran out of credits halfway through the run. A second provider backfilled 41 verified emails, mostly in the regions that got skipped. - The tag field that looked read-only through the API was simply the wrong field. The writable one was lead_tag. Run it in your market: - Chambers of commerce, federations and award programs publish their members and winners. Those pages are segment lists with a reason attached. - Keep the grouping the source gives you (region, sector, year). It becomes your campaign split. - Budget for a second email provider before you start, not after the first one runs dry. Questions: - Q: What counts as a charter or award list? A: Any public page where an organisation lists companies that completed a program: chamber of commerce charters, industry awards, federation members, accelerator cohorts. - Q: How do you find contacts for 300+ companies? A: An asynchronous people search (start a job, poll, fetch) for the right titles, then an email waterfall across two providers. Budget for the second provider before you start. Build prompt (for a coding agent): ``` Build a pipeline that turns a published list of program members or award winners into region-tagged CRM leads with contacts and researched context. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{LIST_URL}}: the public page listing companies that completed a program, grouped by region, sector or year. - {{TITLES}}: the roles to find at each company. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Scrape the list and keep the grouping the source gives you (region, sector, year) as fields. Count first and log it before planning any credits. 2. Resolve each company. Unmatched names may be created here, flagged created_from = "program_list", then enriched with domain and LinkedIn. 3. Contacts: use an asynchronous people-search API (start a job, poll every 3 seconds up to 10 times, fetch). Then an email waterfall across at least two providers, verified emails only. Stop and report if a provider returns out-of-credit errors. 4. Context: for each company run a live web research step with Claude and a search tool. Prompt: list real initiatives with a source URL for each; only facts you actually find, invent nothing; return "nothing found" when empty. 5. Push to the CRM with source and region as properties, not as separate pipelines. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every company has a lead in the CRM with region and source set, contact coverage is reported per region, and every context note either cites sources or says nothing was found. ``` ## Build: Open with a fact from their own report Source: https://kestrelgtm.com/builds/company-reports/ Category: Public data Built for: B2B SaaS · Europe (anonymized client) Works if: Your buyers publish long reports that say what they prioritise. A public index of company reports, each one a long PDF. We pulled every report, had a model extract the facts the client sells on into a fixed schema, and used those facts as the reason to reach out. The signal: A company published a report in the last two years. Why it predicts a purchase: Publishing a report means a team, a budget and a process. The report itself tells you exactly what they measure, what they promised and where the gaps are, which is the whole first email. How it works: - Use the API behind the page: The report index is a WordPress site, so it has a REST endpoint. Paginate that instead of scraping HTML, and you get clean metadata for free. - Check the size before you download: Some reports are over 100MB. Checking size after the download is useless because the timeout already happened. A HEAD request first, then skip anything too large. - Force balance in the schema: Left alone, the model drifted to the best-known theme in each report, while this client sold on a different one. The schema was rewritten to demand equal coverage per theme. - One fact, one email: The email opens on one real initiative from their own report. No template claims, no invented proof: the report is the proof. Build notes: - Routing through a model gateway stripped the PDF content type silently: the model answered that it saw no attachment. Sending the PDF as a base64 data URL fixed it. - Dedup on the report, not the company. Keying on company meant a company with two years of reports only got one extracted. - One report was linked to the wrong company upstream, so the extraction was perfect and wrong. Validate the join, not only the output. Run it in your market: - Annual reports, impact reports, security whitepapers and public filings all say what a company prioritises, in its own words. - Define the fields you want before the first run. A fixed schema beats a summary every time. - Measure field coverage across a sample so you know which facts you can rely on in copy. Questions: - Q: Can AI read 100-page PDFs reliably? A: Yes, with a fixed schema and a coverage check. Here 113 of 130 reports extracted cleanly. Validate that the right PDF is linked to the right company, not only the output. - Q: What goes in the email? A: One real fact from their own report, as the opener. No template claims, nothing invented. Build prompt (for a coding agent): ``` Build a pipeline that reads published company reports and turns one real fact from each into the opener of a first email. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{REPORT_INDEX}}: a public index of company reports. If it is a WordPress site, use its REST API (/wp-json/wp/v2/...) instead of scraping HTML. - {{SCHEMA}}: the fields you want from every report, split evenly across the themes you sell into. - {{TEMPLATE}}: your approved email template. The model only fills its variables. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Paginate the index and store one row per report (dedup on report URL, not on company: a company can have several years). 2. Open each report page and find the PDF link. Send a HEAD request first and skip or stream anything above a size limit; checking size after download is too late. 3. Resolve each report to a company (domain and LinkedIn from a company API), and verify the join: the company in the report title must match the resolved company. 4. Extract {{SCHEMA}} with Claude, passing the PDF as a document. Return strict JSON validated by Zod. Force balanced coverage: every theme gets its own required fields so the model cannot drift to the most famous one. 5. Measure field coverage over a 50-report sample and print it, so you know which facts are safe to use in copy. 6. Build a campaign queue: one real initiative per company as the email opener, the rest of the email from {{TEMPLATE}}. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every report is extracted or has a logged failure reason (too large, dead link, parse error), the coverage table exists, and no extracted record points to the wrong company. ``` ## Build: Turn a competitor's case studies into leads Source: https://kestrelgtm.com/builds/competitor-customer-list/ Category: Competitors Built for: B2B SaaS · Europe (anonymized client) Works if: Your competitors publish case studies or logo walls. A competitor lists its clients as case studies on its own website. Every company on that page already bought from the category. We turned six pages of marketing copy into 32 CRM leads, 20 of them with a verified email and a draft. The signal: A company is featured as a customer on a competitor's website. Why it predicts a purchase: They have budget for the category, a proven buying process and a vendor you can compare against. It is the warmest outside-in list there is. How it works: - Read the real HTML: The first run found zero companies because it assumed heading tags. The page used RDF title spans. Open the source before you write the parser. - Pull names out of prose: Case titles never list the company cleanly. Eleven patterns cover colon splits, possessives, “how we helped X”, “together with X” and more, with 14 real titles as the test spec. - Three providers and a probe: Lusha found 7 emails and 0 of the next 21. Apollo found 12 more. For the rest, generate the common patterns for the domain and verify with an SMTP handshake. That found 1 more and ruled out 7. - Deliver it where the rep works: Each company lands in the CRM with a researched context note, tagged with the competitor as the source, and a draft on the client's own template where a verified email was found. Build notes: - Case sensitivity is load-bearing. The capital-letter check is how the parser tells a name from an ordinary word, so the case-insensitive flag breaks it. - An unverified pattern email from one provider pointed to a completely different organisation. Only keep verified addresses. - The first version ran on a job orchestrator. For a pipeline that runs by hand, plain async scripts were simpler, so it came off again. Run it in your market: - List your top 5 competitors and open their customers, case studies and logo walls. Most have one. - Check review sites and partner directories too: they list reviewers and clients by company. - Lead with a comparison the prospect can check, never with the fact that you scraped the page. Questions: - Q: Is it OK to use a competitor's public customer list? A: It is public information. Use it to know who already buys the category, and lead with a comparison the prospect can check. Never say you scraped the page. - Q: What if the company names are hidden in the titles? A: Parse them out. Here eleven patterns covered colon splits, possessives and phrases like “how we helped X”, tested against real titles. Build prompt (for a coding agent): ``` Build a pipeline that turns a competitor's public case studies into qualified CRM leads. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{CASE_STUDY_URLS}}: the competitor pages that list their clients (case studies, logo wall, "our customers"). - {{TITLES}}: the roles to reach. - {{TEMPLATE}}: your approved email template. The model only fills its variables. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Fetch the pages and read the real HTML before writing the parser. Extract every case title or logo alt text. 2. Company names are usually hidden inside marketing sentences. Write a name extractor as a list of patterns (colon split, possessives, "how we helped X", "together with X", "for X", prefix nouns) with the real titles as unit tests. Keep capital-letter checks case-sensitive. Log titles with no company as skipped. 3. Resolve and dedup: two spellings of the same company must become one record. 4. Contacts: email waterfall, provider A, then provider B, then pattern guessing verified by an SMTP handshake (RCPT TO: 250 keep, 550 drop). Keep only verified addresses. 5. Context note per company: what they did in the category, researched live with sources. 6. Push to the CRM tagged with the competitor as the source, with a draft built on {{TEMPLATE}}. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). - Copy leads with a comparison the prospect can verify. It never says where the list came from. Done when: every extracted title is either a company in the CRM or a logged skip, and the email coverage is reported as found / tried per provider. ``` ## Build: Reach people engaging with competitor posts Source: https://kestrelgtm.com/builds/competitor-post-engagers/ Category: Competitors Built for: B2B SaaS · Europe (anonymized client) Works if: Your buyers follow competitors or category voices on LinkedIn. A scheduled scraper watches the LinkedIn posts of 15 competitors, collects every reaction and comment, enriches the people, and runs every person through an AI agent that checks ICP fit. The signal: Someone reacted to or commented on a competitor's post. Why it predicts a purchase: They are paying attention to the category right now, and they told you which vendor they follow. That is a topic signal and a competitive signal in one click. How it works: - One key per reactor per post: Dedup on post plus reactor, so ten people reacting to one post become ten signals and the same person on the next run does not. - Best provider first, fallbacks after: A three-step cascade: the best-quality provider first, a backup API second, raw scraping last. - An agent inside a normal workflow: The ICP check is a real agent with a think step and two search tools that it chooses between, living inside an otherwise deterministic pipeline. Build notes: - The tracker was built and then never wired to a trigger. It sat idle for two weeks. Check that a workflow runs, not only that it exists. - Signal types follow a fixed taxonomy: reaction, comment, profile_visit. - Inline JSON in SQL broke five runs in a row. Parameterised queries only. Run it in your market: - Pick the 5 to 15 accounts your buyers follow: competitors, category creators, the loudest analyst. - Weight comments above likes and read them. - Mention the topic in your outreach, never the like. Questions: - Q: Should I mention that they liked a competitor's post? A: No. Mention the topic, never the like. The engagement tells you who is paying attention, not what to say. - Q: Are comments better than likes? A: We would weight them higher: comments often state the pain in their own words. Build prompt (for a coding agent): ``` Build a scheduled pipeline that collects people engaging with competitors' LinkedIn posts, qualifies them and stores them as company-linked signals. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{ACCOUNTS}}: 5 to 15 LinkedIn company or founder pages your buyers follow. - {{ICP}}: a plain-language description of a good-fit company and role. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. A Trigger.dev cron runs on the cadence you pick (weekly is enough for most accounts) and processes each account in {{ACCOUNTS}}. 2. For each account, scrape the latest posts, then reactions and comments per post. Dedup key: post_url + person id, so many people on one post are separate signals and repeats are not. 3. Enrich each person with a cascade: best-quality provider first, backup API second, scraping last. Stop at the first hit. 4. Qualify with a Claude agent that has two search tools and decides which to call, then returns { fit: boolean, reason, company_domain } against {{ICP}}. 5. Write signals of type "reaction" or "comment" (comments weigh more; store the comment text) on the resolved company. 6. Keep your own team's activity out: exclude your own profiles and pages before writing anything. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: a run reports posts, engagements, enriched, qualified and written counts per account, there are zero signals without a company, and a same-day re-run writes nothing new. ``` ## Build: Find out which competitor tool they run Source: https://kestrelgtm.com/builds/competitor-tool-detection/ Category: Competitors Built for: B2B software vendor · EU (anonymized client) Works if: You replace a tool your buyers already run. The client sells software that replaces a tool prospects already run. We built detection that works out which competing tool an account uses from five public sources, writes it to the CRM only when confidence is high, and watches for competitor outages as a timing trigger. The signal: An account runs a competing tool, detected from code, jobs, skills, reviews or case studies. Or that competitor just had an outage. Why it predicts a purchase: Knowing the incumbent turns a generic pitch into a replacement pitch with a concrete comparison. An outage is the week that comparison lands. How it works: - Look where engineers leave traces: Public repositories, job descriptions and profile skills name the libraries a team uses. Reviews and case studies name the vendor. - Explain the disagreements: When one source says tool A and another says tool B, the system reasons over the conflict instead of picking the first hit. The fix targeted inferred signals that were being ignored, and improved capture rate and confidence scoring. - Watch the competitor, not just the account: Competitor outages are monitored as a timing trigger. Build notes: - Documents attached to CRM records are a source too. A second flow reads them for competitor mentions whenever a new file lands. - A detection only fills the CRM field at high confidence. A wrong incumbent in the first line of an email ends the conversation. Run it in your market: - Write down the 3 to 5 tools you replace and the words engineers use for them (library names, not brand names). - Search job posts and public repos for those words first. It is free and it is the most honest source. - Subscribe to competitor status pages. Outages are rare, so treat one as a clear timing trigger. Questions: - Q: Where can you see which tool a company uses? A: Public code repositories, job descriptions and profile skills name the libraries. Review sites and case studies name the vendor. Keep the evidence so a rep can quote it. - Q: Why only high-confidence detections? A: A wrong incumbent in the first line of an email ends the conversation. Weak detections stay in the database, not in the CRM field. Build prompt (for a coding agent): ``` Build a detector that works out which competing tool an account runs, with evidence, and only writes high-confidence answers to the CRM. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{COMPETITORS}}: the tools you replace, each with the words engineers use for them (library names, package names, not only brand names). - {{ACCOUNTS}}: the account list to check (CRM export or table). Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. For each account, search five sources: public code repositories, job posts, employee profile skills, review sites, and the account's own case studies or docs. Store every hit as evidence: source, URL, snippet, date. 2. Classify each hit as stated (the vendor is named) or inferred (a library or requirement points to it). 3. When sources disagree, ask Claude to reason over the dated evidence and return { tool, confidence: high | medium | low, why }. Recent and stated beats old and inferred. 4. Write a "tool_detected" signal with the full evidence in attributes. 5. Only high-confidence results update the CRM field. Everything else stays in the database for review. 6. Also read documents attached to CRM records when a new file lands: past proposals may name the incumbent. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: every account has a result with evidence or "none found", the CRM field is filled only for high confidence, and a rep can open the source URL for every detection. ``` ## Build: Turn website visitors into qualified leads Source: https://kestrelgtm.com/builds/website-visitors/ Category: Your own data Built for: B2B SaaS · Europe (anonymized client) Works if: You already identify companies visiting your site. The client already identified companies visiting its site. We turned that raw feed into clean, company-linked signals, ran each company through a disqualify-only ICP filter, and routed the survivors to the right pipeline. The signal: A company visited the client's website. Why it predicts a purchase: On its own it shows interest, not intent. Combined with a stronger signal it tells you who to contact first. How it works: - Match strictly: A web visit is not strong enough evidence to create a company. Only a LinkedIn URL or domain match counts. Most matched on domain. - Flip the filter: An allow-list of approved sectors was starving the pipeline. It became a short block-list of clear misfits with one rule: if unsure, qualify, so more leads reach the rep by default. - Filter by country at the first hop: Non-local visitors leaked into the pipeline because the country field never reached the sub-workflow. Filter where the data enters, not three steps later. Build notes: - A no-code sub-workflow only receives the fields you map explicitly. The missing country field was a silent failure, not an error. - In a 12-company test, the AI sector check returned malformed JSON once. The dedup cache made the retry free. Run it in your market: - Use visitor data to rank accounts you already had a reason to contact, not as a reason on its own. - Start with a block-list, not an allow-list. You learn more from what gets through. Questions: - Q: Why disqualify-only instead of an allow-list? A: An allow-list starved the pipeline. A short block-list of clear misfits with “if unsure, qualify” sends more leads through and lets the rep decide. Build prompt (for a coding agent): ``` Build a pipeline that turns identified website visitors into company-linked signals, filters out clear misfits and routes the rest. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{VISITOR_FEED}}: the table or API of companies identified from site traffic. - {{BLOCKLIST}}: the company types that never buy from you. - {{COUNTRIES}}: markets you serve. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Every 6 hours, read new visits. Filter by {{COUNTRIES}} at this first step, not later. 2. Resolve strictly: domain or LinkedIn URL only. A visit is not enough evidence to create a company; drop unmatched visits with a logged reason. 3. Write a "web_visit" signal per company per day (dedup on company + date), with pages viewed in attributes. 4. Disqualify-only filter: scrape the homepage, ask Claude to classify the company against {{BLOCKLIST}}, return strict JSON. Block only clear misfits. If unsure, qualify. Cache the decision per domain so retries are free. 5. Route qualified companies to the pipeline a rep actually works, tagged with the source. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). - Visits decide who is contacted first. No prompt or template may reference the visit. Done when: there are zero signals without a company, the filter decision and reason are stored per domain, and every qualified company lands in a pipeline a rep works. ``` ## Build: Build lists from data you already own Source: https://kestrelgtm.com/builds/db-first-tam/ Category: Your own data Built for: B2B SaaS · Europe (anonymized client) Works if: You have a CRM or database you have never fully worked. The brief was a list of companies in one industry, with the people in one function. The plan was an 8-script scrape of a company register and an industry directory. Then we checked the client's own database: 1,490 of those companies were already there, 381 of them already scored. The contact search took 38 minutes. The signal: Companies the client already had and had already scored. Why it predicts a purchase: Before buying or scraping anything, the question is what is already sitting in your database. Here it was 1,490 companies, with domains for almost all of them. How it works: - Look before you scrape: The daily sync question was simple: do we already have this? The client's database held 1,490 companies in the segment, 1,419 with a domain. Three of the eight planned scripts were no longer needed. - Spend credits on the likeliest buyers: 381 companies already had a maturity score. Contact search ran on those only. Smaller companies rarely have a dedicated role for that function anyway. - Batch, do not retry: A batch that returns zero usually means the companies are not on LinkedIn. Retrying one by one would have taken 8+ hours. Batches of five without retries took 38 minutes. - Deliver in the shape they use: Contacts land in one column per role instead of Contact 1, 2, 3, because which roles are filled is itself the signal. Build notes: - One enrichment endpoint was dead mid-run (404 for every domain). Employee counts were dropped rather than blocking the delivery. - The contact provider is blocked from local machines by its CDN, so calls went through a small server-side proxy. - The remaining 1,109 unscored companies were left as a documented expansion: 3 to 4 hours and 100 to 150 credits if the client wants more volume. Run it in your market: - Start every list build with a count of what is already in your CRM and warehouse. - Rank by what you already know about the account before you pay for contacts. - Hand over the list in the columns the rep will actually filter on. Questions: - Q: What should I check before buying or scraping a list? A: Count what is already in your CRM and warehouse, and what you already know about each account. Here 1,490 companies were already there, 381 of them scored. - Q: How do you keep enrichment costs down? A: Only pay for contacts at the accounts most likely to buy, and only for hits: 94 emails for 94 credits. Build prompt (for a coding agent): ``` Build a list for a new segment starting from the data you already own, and only pay for contacts where it matters. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{SEGMENT}}: the segment you need (industry, size, region). - {{ROLES}}: the roles to deliver, as separate columns. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Before anything else, query your own database and CRM for {{SEGMENT}}. Report: companies found, how many have a domain, how many have existing scores or history. 2. Only if coverage is poor, fill the gap from an official company register or industry directory, and merge on normalised name and domain. 3. Rank: accounts you already scored or worked first. Run contact search only on the top tier first. 4. People search in batches of five with no per-company retries (a zero result usually means the company is not on LinkedIn; a retry loop costs hours). 5. Email lookup by LinkedIn URL only, pay per hit, verified emails only. 6. Deliver with one column per role in {{ROLES}}, because which roles are filled is itself a signal. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). Done when: the report shows what came from your own data versus bought, credits spent versus emails found, and runtime. ``` ## Build: Find contacts at companies with 2 to 10 staff Source: https://kestrelgtm.com/builds/evidence-first-people-search/ Category: Tooling Built for: GTM agency · Europe (anonymized client) Works if: Your buyers work at companies too small for contact databases to cover. An agency's client had 1,593 target companies with 2 to 10 staff and fewer than two reachable contacts each. Reachable meant a work email or a LinkedIn profile. We fetched evidence per company first, had Claude subagents judge it, then looked people up anchored to the company and ran a still-at-company check. The handback held 2,145 people and 1,128 verified emails. What it solves: A named person on the company's own LinkedIn page, website, search results or company register filing. Why it matters: At this size a contact database rarely has the people, and a name plus domain lookup often returns someone who works somewhere else. The company's own pages name the team directly. How it works: - Fetch evidence before any lookup: A health check removes dead and parked domains. Then plain code collects the public LinkedIn company page, a crawl of the site and two web searches into one evidence bundle per company. - Let the model judge, not fetch: Claude subagents read each bundle, reject fakes and decide: is this a real person, is this their current employer, are they senior, is the company in scope. Splitting deterministic fetching from model judgment moved precision from 60% to 87%. - Anchor every lookup to the company: Apollo runs company first, then people inside that company, then a reveal by id, and an email only ships when its domain matches the company. Icypeas, the national company register for directors, Clay people search and an email waterfall fill the rest, with BounceBan on the catch-all and unknown tail. - Check they still work there: A Clay verifier ran on all 1,273 people with a LinkedIn profile. It dropped 114 who had moved on and corrected 615 titles. The merge was deduped on LinkedIn slug, then email, then name plus domain. Build notes: - A first version, website plus database lookup, topped out at about 15% of companies with two contacts. - Apollo's person match treats the domain as a current or previous employer. On firms this small it returned someone at a different company 42% of the time (31 of 73). - Against the full list, 35% of the companies that had nobody now have two reachable contacts, and 73% of those that had one got their second. An earlier count dropped suspected misfits from the denominator and read about 9 points higher. A re-check found 74 of 106 were real targets. The remaining flags went to the client to confirm. - The public LinkedIn company page, not the people tab, lists named employees. It was free, had no quota and was the biggest single source of names. Run it in your market: - At companies of up to ten people, read the company's own pages before paying for any database lookup. - Anchor every lookup to the company and ship an email only when its domain matches. - Report coverage against the full list. When the client owns the scope call, flag suspected misfits for them instead of removing them from the count. Questions: - Q: Why not start with a contact database? A: At 2 to 10 staff the databases barely know these people. Two email finders returned about 1% and 17% on this list. The company's own LinkedIn page named employees at 55 to 70% of companies, for free. - Q: Why is coverage not higher? A: Many of these firms are one person, so a second contact does not exist. About 47 more had dead domains and no findable person anywhere. Build prompt (for a coding agent): ``` Build a people search for companies too small for contact databases: fetch evidence first, let Claude judge it, look people up anchored to the company, and verify every person still works there. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, the Anthropic SDK for judgment steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{COMPANIES}}: the target list with domain, company LinkedIn URL and any contacts you already hold per company. - {{TARGET}}: reachable contacts wanted per company (a work email or a LinkedIn profile counts). - {{TITLES}}: the roles to prefer, in order, plus the fallback rule when a company cannot give you enough senior people. Data model (create what is missing): - companies(id, name, domain unique, linkedin_url, country, status) - evidence(company_id, source, url, fetched_at, payload jsonb) - contacts(id, company_id, name, title, linkedin_url, email, email_status, still_at_company, source, source_url) Steps: 1. Health check every domain. Mark dead and parked sites and keep them in the report, not the search. 2. Collect evidence with plain code, no model: the public LinkedIn company page (the company page, not the people tab), a crawl of the site's team, about and contact pages, and two web searches per company. Store every fetch in evidence. 3. Give each company's evidence bundle to a Claude subagent. It returns, per candidate: real person or not, current employer or not, seniority against {{TITLES}}, company in scope or not, and the source URL. Reject anything without a source. 4. For companies still short of {{TARGET}}, look people up anchored to the company. Resolve the company record by exact domain first, search people inside that company only, reveal by id. Never match on name plus domain alone: some providers treat the domain as a current or previous employer. 5. Add directors from the official company register where the country has one. Run the remaining gaps through a people search and an email waterfall. Send catch-all and unknown results to a separate verifier pass. 6. Run a still-at-company check on everyone with a LinkedIn profile. Drop people who moved on and replace titles with the current one. 7. Dedupe on LinkedIn slug (not the full URL), then email, then name plus domain. Skip anyone you already hold. Rules: - Ship an email only when it is verified and its domain matches the company domain. - A blank is better than a guess. No name goes in the file without a source URL. - Report coverage against the full list. Suspected out-of-scope companies are flagged for the owner to confirm, never removed from the count. - Log a count after every step and fail loudly when a step that should return rows returns zero. Done when: every company is covered, short with a reason, or flagged for review; every person has a source, a still-at-company result and a verified email or none; and the report shows coverage and credits spent per provider. ``` ## Build: Build lead lists with checks before shipping Source: https://kestrelgtm.com/builds/lead-list-checks/ Category: Tooling Built for: Lead-gen agency · Europe (anonymized client) Works if: Your lead lists go straight into email campaigns and cold calls, and one wrong name costs you trust. The agency's team filled in a form for every list they needed. Claude turned the form into a structured brief, a person approved it in Slack, and the pipeline sourced companies and found a decision-maker at each one. The first version delivered lists for seven of the agency's clients within three weeks. The checks were added as delivered lists came back with wrong names, and they decide what reaches the CRM file. What it solves: A request from the team's form: the product, the countries, the target job titles and a cap on companies. Why it matters: The lists go into email campaigns and cold calls. A wrong name, or three people mailed at the same company, looks careless or like spam and costs the agency trust with its own client. How it works: - Approve the brief before spending: Claude reads the form, its comments and notes and writes a brief: countries, target titles, company cap and people per company. The brief lands in Slack, where a person approves, rejects or edits it in the thread. Without a reaction in 30 minutes it goes ahead. - Source, then find the decision-maker: Companies come from Google Maps for local businesses, from Apollo and Google search for a pattern, or from a list the client already has. Apollo, Perplexity and a LinkedIn search propose the decision-maker. Google Places and a scrape of the company's own site add phones and emails. - Check every name that has a profile: One person cannot hold the top spot at two companies on the list. Phones must match the brief's country when the brief asks for it. Claude reads each person's LinkedIn result and Apollo record and returns match, wrong employer or uncertain. Then 10% of rows are re-checked with the team's own manual searches, and more than 10% misses fails the list. - Ship a file the CRM accepts: Rows are ranked and trimmed to the brief's cap and written as a 19-column HubSpot import file, posted to Slack. Only a list that passes goes to Clay for email finding. On the way back, emails on another domain, invalid emails and foreign mobiles are dropped. Build notes: - The first finder marked a person verified as soon as any LinkedIn profile with that name existed. The employer check was run outside the pipeline, list by list, five weeks in a row before it became a default step. - Rows carried over from an earlier delivery skipped the LinkedIn check. After rows were flagged on the delivered sheet, the lookup on the 11 carried-over rows found 7 pointing to the wrong person. The rule became: check every row, including rows from a previous list. - The maps data gave one fallback phone number to 29 different companies. The fix was to take the office phone from each company's own contact page, which had a number for 51 of 60. - A strict verified-only filter dropped all 119 rows on one small-business brief, because Apollo had no LinkedIn URLs for those owners. - Most deliveries ran as short per-list scripts on the same library, because the LinkedIn audit and the Clay return filters ran there and not in the scheduled flow. Run it in your market: - Put a person between the brief and the first paid lookup. - Check the current employer of every name, including rows reused from an earlier list. - When no name passes, ship the company without one. Never ship a guessed name or a pattern-guessed email. Questions: - Q: Why check the employer when the tool already says the LinkedIn profile is verified? A: That flag only means a profile with the name exists. The check reads Google's indexed LinkedIn title and Apollo's current employer for the person and asks whether they work at this company today. The first audit flagged 6 of 77 LinkedIn URLs as someone else. - Q: What happens to a company where no name passes? A: It ships without a name, with the company's general contact details, so the list still covers it. An earlier version dropped those rows silently and lost 8 of the 19 companies on the client's source list. Build prompt (for a coding agent): ``` Build a lead-list pipeline that turns a request form into a CRM-ready file, and runs checks on every name before anything ships. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for the form poll and the pipeline, the Anthropic SDK for the brief and the verdicts. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{FORM}}: the request form your team fills in per list (product, countries, target titles, company cap, people per company, notes). - {{APPROVAL_CHANNEL}}: the Slack channel where a person approves each brief. - {{CRM_COLUMNS}}: the exact import columns your CRM expects, in order. - {{EMAIL_WATERFALL}}: the webhook of your email-finding and verification tool. Data model (create what is missing): - briefs(id, request_id, brief jsonb, status, approved_by) - lists(id, brief_id, status, row_count, coverage_pct, audit_result jsonb) - rows(id, list_id, company_name, domain, phone, dmu_name, dmu_title, linkedin_url, email, check_verdicts jsonb, evidence text) Steps: 1. Poll {{FORM}} every 5 minutes. Have Claude turn each new request, its comments and notes into a brief validated by Zod: sourcing mode, countries, target titles in priority order, company cap, people per company. 2. Post the brief to {{APPROVAL_CHANNEL}}. A reaction approves or rejects; a thread reply edits it and re-runs step 1. Decide up front what happens without a reaction and log it. 3. Source companies by mode: a maps search per city for local businesses, a company or keyword search for a pattern, or the client's own list. Take phones and emails from the company's own contact page, not from the maps listing. 4. Find decision-maker candidates per company from at least two sources (a people database, a web-search model, a LinkedIn search) and keep the full candidate list as evidence. 5. Checks, in this order: a. One person, one company: if the same top name appears at two companies, keep it only where the surname is in the company name, else the first occurrence. b. Country: when the brief requires it, drop rows whose phone country code contradicts the brief. c. Employer: for each top name with a LinkedIn URL, fetch Google's indexed title for that exact profile and the people database's current employer. Claude returns match, wrong_employer or uncertain with a reason. On wrong_employer, try the next candidate, up to 3. If none pass, keep the company with no name. d. Sample audit: re-check a deterministic 10% of rows (30% under 20 rows) with the searches your team does by hand. More than 10% misses fails the list. 6. Rank rows and trim to the brief's cap, at most the brief's people per company. Write {{CRM_COLUMNS}} and post the file to Slack. If the list failed a check, post it with a warning and do not send it to {{EMAIL_WATERFALL}}. 7. When the waterfall returns, merge on domain, first name and the last token of the surname. Drop emails on another domain, emails marked invalid and mobiles from another country. Rules: - A LinkedIn profile with the right name is not proof of employment. Check the current employer on every row, including rows reused from an earlier list. - Never ship a pattern-guessed email. Only an address the finder returned and the verifier did not reject goes in the Email column. - Never drop a company the client supplied. Ship it without a name when no name passes. - Store every check verdict and its reason on the row. - Log a count after every step and fail loudly when a step that should return rows returns zero. Done when: every shipped name has an employer verdict, no person appears at two companies, the audit result is stored per list, and a list that failed a check never reaches the email waterfall. ``` ## Build: Put every signal in one table Source: https://kestrelgtm.com/builds/signal-layer/ Category: Your own data Built for: B2B SaaS · Europe (anonymized client) Works if: You run more than one signal source and want one view per account. Every source for this client writes into the same place: one signal table where each row is linked to one company. On top of it sits a routing step and a human approval before anything sends. The signal: Everything that happened to an account, from any source, in one place. Why it predicts a purchase: One signal is a hint. Three signals on the same company in the same month is a reason. You only see that if every source lands in the same table. How it works: - Stage raw, then bridge: Every source keeps its own raw table. A bridge step resolves each row to a company and writes one normalised signal: type, company, source URL, date. - Resolve once, audit always: Matching runs domain, LinkedIn, exact name, cleaned name, fuzzy, then an AI check, and every match stores which tier matched and why. You can always answer why a row landed on that company. - Route to a rep: Each account is routed to the pipeline a rep actually works, with every signal and its source in the note. - Pick the runtime for the job: Built in no-code first, moved to Python when a node silently returned zero rows, then to TypeScript jobs. Scheduled fan-out work earned the orchestrator. Scripts run by hand did not. Build notes: - Two reasons to move: a no-code node silently returned zero rows, and the hosted bill. - A migration claimed all flows were running. Only one of 14 had actually been triggered. Every flow was then run and checked by hand before merging. - Human approval stayed on purpose. The system drafts and explains, a person decides what goes out. - The profile-visit feed was mixing the agency owner's own LinkedIn visitors into the client's intent data. 18 of 41 signals were removed before they skewed anything. Run it in your market: - Give every signal the same four fields: type, company, source, date. Everything else can live in an attributes column. - Resolve identity at ingestion, never later. - Add a new source as a staging table plus a bridge. If it takes more than a day, the layer is wrong. Questions: - Q: What fields does every signal need? A: Type, company, source URL and date. Everything else can live in an attributes column. - Q: When is an orchestrator like Trigger.dev worth it? A: For scheduled fan-out work with retries and dashboards. A script you run by hand does not need one. Build prompt (for a coding agent): ``` Build the layer every signal source writes into: one table, one company per row, routed, with a human approving outreach. Stack: TypeScript (strict, ESM), Zod on every external payload, Neon Postgres, Trigger.dev for anything scheduled, the Anthropic SDK for LLM steps. Put every enrichment provider behind one interface so it can be swapped. Inputs: - {{SOURCES}}: the signal sources you already have or plan (registries, reports, competitor engagement, visits, hiring). Data model (create what is missing): - companies(id, name, domain unique, linkedin_url unique, country, created_from) - signals(id, company_id not null, type, dedup_key, source_url, occurred_at, attributes jsonb, unique(type, dedup_key)) - contacts(id, company_id, name, title, linkedin_url, email, email_verified) Steps: 1. Every source keeps its own raw staging table with its natural shape. 2. A bridge per source resolves each raw row to a company and writes one normalised signal: type, company_id, source_url, occurred_at, dedup_key, attributes. Resolution runs once, at ingestion, never later. 3. A view lists each account with its signals from the last 90 days. 4. Routing sends accounts to the pipeline a rep works, with a context note listing the signals and their sources. 5. Drafts are created for a person to approve and send. Nothing sends automatically. 6. Schedule each bridge as its own Trigger.dev task. Manual one-off scripts stay plain scripts. Rules for the whole build: - Every signal resolves to one company. Match on domain, then LinkedIn URL, then exact name, then name with legal suffixes stripped, then trigram similarity above 0.5. Store which tier matched and why. - All writes are idempotent upserts on a dedup key. Running it twice changes nothing. - Keep only verified emails. Never guess an address into the CRM. - Log a count after every step and fail loudly when a step that should return rows returns zero. - Outreach copy never mentions how we found them (tracking, scraping, likes). - Adding a new source means one staging table plus one bridge. If it takes more than a day, fix the layer, not the source. Done when: every source writes through its bridge, there are zero signals without a company, and a single query returns an account's full signal history with sources. ``` ## Skill: Turn a LinkedIn post's engagers into a HeyReach list Source: https://kestrelgtm.com/skills/linkedin-post-engagers/ Skill file: https://kestrelgtm.com/skills/linkedin-post-engagers/SKILL.md Run: /linkedin-post-engagers "List name" Category: Public data Built for: Kestrel's own outbound (anonymized client) Works if: Your buyers react to and comment on LinkedIn posts in your space. Give it a LinkedIn post. It collects everyone who reacted or commented, looks up their companies, qualifies each person against your ICP with AI, shows you the list for approval, and loads the approved people into a HeyReach list ready for a campaign. The signal: Someone reacted to or commented on a post about your problem space. Why it predicts a purchase: Engagement is recent, public and on topic. A comment often states the pain in their own words, which is the best opener you will get. How it works: - Collect the engagers: Comments and reactions, one record per person. Someone who commented and reacted counts once, as a comment. - Cut the noise cheaply: Open-to-work profiles, students, recruiters and company pages go before any AI runs. - Qualify with agents: Persona, business model and a competitor check per person, with a confidence score. A web lookup only when company data is thin. - Approve, then load: Nothing goes to HeyReach until you approve the list. Approved people land in a fresh list you attach to a campaign. Build notes: - Pick posts whose engagers are your buyers, not your peers. An influencer post in your own field is mostly peers and agencies. - Drop competitors explicitly. They pass every firmographic filter. - HeyReach's list endpoint wants profileUrl, not linkedInUrl, and list names max out at 50 characters. Run it in your market: - Save posts your buyers comment on during the week and run them together. - Open with the topic of the post, never with the fact that they engaged. Questions: - Q: Do I need a paid Apify plan? A: The free plan covers a few posts a month. Large or viral posts need a paid plan, because every engager is a profile scrape. - Q: Does it send messages? A: No. It builds and loads the list. You attach the list to a campaign and write the sequence in HeyReach.