Build a verified list
One sentence in, 100 checked work emails out, each at the right company, with the quote that shows the fit.
- Kind
- Workflow
- Price
- $1.38 per 100 emails
- Your time
- 8 minutes
- Approval
- Runs on its own
- Deepens
- Outbound
Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first.
curl -X POST "https: //api.sb/ leaseline.app/ audiences/ build? mode= try" \
-H "Authorization: Bearer $SB_TOKEN" \
-H "Content-Type: application/ json" \
-d '{"goal": "Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1, 000 units, spend up to $4, and show me the plan first.", "budget": {"amount": 4, "currency": "USD"}}'
npx startup-builder do Audience.build --try --data '{"goal": "Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1, 000 units, spend up to $4, and show me the plan first.", "budget": {"amount": 4, "currency": "USD"}}'
{
"name": "do",
"arguments": {
"mode": "try",
"code": "return await $.Audience.build({ goal: 'Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1, 000 units, spend up to $4, and show me the plan first.', budget: { amount: 4, currency: 'USD' } })"
}
}
import { $ } from 'startup-builder'
const result = await $.try.Audience.build({ goal: 'Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1, 000 units, spend up to $4, and show me the plan first.', budget: { amount: 4, currency: 'USD' } })
Prospect.countwith the company filters, free, to size each query before a row is bought.Prospect.findfrom the query that sizes best, then screen each company against your sentence.ContactPoint.findEmail, cheapest route first, at most $0.03 a person.ContactPoint.verifyEmail, free routes first.- Repeat until 50 pass.
Planning is free. You see the plan and its price before anything runs.
Decisions
- D1, rows 1 to 5size four queries for free, then buy 150 rows from the closest one.All real estate matched 5,150 people. Adding the keyword “property management” brought it to 882, the nearest fit to your sentence, so row 5 bought from that query.
- D2, rows 54 to 57find 4 addresses again, by name and the current company’s domain only.The first find for these 4 people returned an address at another company, such as gwinnettcounty.com for a partner at tmg-atl.com. An address at a past employer bounces or reaches the wrong person.
- D3, rows 105 to 112 and 136send 9 catch-all and risky results to
ContactPoint.verifyEmail({ depth: 'deep' }), at $0.004 each.A catch-all domain accepts every address, so the standard check cannot confirm the mailbox. All 9 came back deliverable. - D4, rows 113 to 116find 4 addresses again, leaving out the route that produced each one.The check called them invalid. One came back new and valid; the other 3 stay off the list, each with its reason below.
- D5, rows 118 to 123re-size four narrower queries for free, then buy the 51 rows of “Leasing Residential Real Estate”.The second page of the first query came back empty. That industry was the most precise match, and small enough to read whole.
- D6keep 10 addresses at a domain other than the company’s, such as choicenewyork.com for choice-ny.com.Each checked deliverable, and none sat at a past employer on the person’s profile, which is what D2 tests for.
By call
| Call | Calls | Cost | Outcomes |
|---|---|---|---|
Prospect.count | 8 | $0 | 8 counted |
Prospect.find | 3 | $0.05738 | 3 listed |
ContactPoint.findEmail | 62 | $0.172 | 60 found · 2 none found |
ContactPoint.verifyEmail | 54 | $0.026 | 4 invalid · 41 deliverable · 7 risky · 2 catch-all |
ContactPoint.verifyEmail({ depth: 'deep' }) | 9 | $0.036 | 9 deliverable |
| Spent | 136 | $0.29 | of $4.00, $3.71 left |
Agent time 21 minutes, in one sitting.
Every call, 136 rows
Loading the rows.
What you get
- 100 checked emails · One sentence in, 100 checked work emails out, each at the right company, with the quote that shows the fit. · Audience
audience· The saved list, as an Audience you can enroll, sync to an ad account or refresh.rows· One row per person: company, domain, person, title, email and its check result, with the quote that shows fit.funnel· How many matched, fit, had a person, had an email at the right company, and checked valid: the counts that tell you which filter to loosen.skipped· Every row it dropped, by reason and count: no email found, wrong company, catch-all, invalid, already a contact.cost· What it spent each time it ran, and its cap.
How long: About 3 to 6 minutes; the email finds set the pace.
Trigger
It runs when you call it. List refresh re-reads the Audience each month and on every bounce.
every.month(($) => $.Audience.refresh({ audience: 'audiences/audience_31' }))Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.
| Step | Verb | Returns | Price | Approval |
|---|---|---|---|---|
| 1. Count the market | Organization.count | How many companies match, free, before a row is bought | Free | Runs on its own |
| 2. Find the companies and ask each site the fit question | Organization.find | Three companies for every email wanted, each with fit and a quote code finds verbatim on the page | About 60¢ | Runs on its own |
| 3. Find the people | Prospect.find | One person per strong-fit company with a matching title; names and titles are free | Free | Runs on its own |
| 4. Find each work email | ContactPoint.findEmail | An address for about 85 in 100, in fit order; a miss is free | About 54¢ | Runs on its own |
| 5. Drop wrong-company addresses and find again | ContactPoint.findEmail | Any address whose domain is not the account’s domain dropped, and found again from the person’s current profile URL; about 1 in 12 | About 4.5¢ | Runs on its own |
| 6. Check each email | ContactPoint.verifyEmail | valid, catch-all, invalid or unknown; catch-all and unknown cost nothing | About 20¢ | Runs on its own |
| 7. Drop who you already know | Collection.query | The rows not already a contact, a customer, suppressed or on an open deal | Free | Runs on its own |
| 8. Save the Audience | Audience.create | One Audience of size rows, linked to this call | Free | Runs on its own |
Calls: Organization.count, then Organization.find, then Prospect.find, then ContactPoint.findEmail, then ContactPoint.verifyEmail, then Collection.query, then Audience.create.
About $1.38 per 100 checked emails, each time it runs, capped at $2.00.
Example: give it a goal
Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first.
Run it above shows the plan it showed before it spent anything, and the receipt of every call.
When something fails
Each failure, and what happens next
| When | Then |
|---|---|
| A site cannot be read | The company reads fit: and stays out of the list; it is counted under skipped as site-unreadable. |
| No email is found for a person | The row is skipped as no-email-found, and the miss costs nothing. |
| A find returns an address at another company | Code drops it and finds again from the person’s current profile URL on the account’s own domain. In the 50-lead goal, 4 first finds pointed at other companies (one at a county government domain), so the run reads 50 verified, 46 at the right company on the first find, 4 replaced. |
| The check says catch-all or unknown | It settles at $0 and stays out unless accept includes catch-all; a deep check (ContactPoint.verifyEmail with depth:) resolves it for about 0.4¢. |
size cannot be reached under maxCost | It returns what it found, with a max-cost skip count and a resume link that carries on from the last company read. |
Each step retries three times, then falls through to the next route; one item's failure lands in skipped with its reason, never failing the call. Before a step would cross maxCost it stops and returns what it has, with a resume link. Each step's idempotency key is the call's key plus the step and the item: leaseline-audience-build-1-<step>-<item>.
Change it
| Ask | Compiles to |
|---|---|
| Count catch-all addresses too, in their own batch. | Audience.build |
| Only companies hiring a leasing coordinator this month. | Organization.find |
| Keep this list fresh every month and drop anyone who bounces. | Audience.refresh |
import { $, workflow } from 'startup-builder'
/** An address is at the right company only on the account's own domain. */
const sameDomain = (address: string, hostname: string) => address.split('@')[1]?.toLowerCase() === hostname.replace(/^www\./, '').toLowerCase()
export default workflow({
name: 'audience-build',
verb: 'Audience.build',
input: { icp: 'string', question: 'string?' , titles: 'string[]', size: 'integer', perCompany: 'integer', accept: "('valid' | 'catch-all')[]" },
maxCost: 2,
execute: async (ctx, { icp, question, titles, size = 100, perCompany = 1, accept = ['valid'] }) => {
const market = await ctx.step('count', () => $.Organization.count({ where: { description: icp } })) // free
if (market.count < size * 3) return ctx.refuse('market-too-small', 'Widen the customer sentence, or lower size' )
const orgs = await ctx.step('companies', () => $.Organization.find({ where: { description: icp }, question, limit: size * 3, maxCost: 0.7 }))
const fit = orgs.organizations.filter((o) => o.fit === 'strong' && o.evidence.quoteFound) // a declared rule
const people = await ctx.step('people', () => $.Prospect.find({ organizations: fit.map((o) => o.hostname), titles, perCompany }))
const checked = []
for (const p of people.prospects) { // fit order
if (checked.filter((c) => accept.includes(c.status)).length >= size) break // stop at size
let found = await ctx.step(`find: ${p.$id}`, () => $.ContactPoint.findEmail({ prospect: p.$id, maxCost: 0.02 }))
if (found.address && !sameDomain(found.address, p.organization.hostname)) // wrong company
found = await ctx.step(`refind: ${p.$id}`, () => $.ContactPoint.findEmail({ prospect: p.$id, from: p.profileUrl, domain: p.organization.hostname, maxCost: 0.02 }))
if (!found.address || !sameDomain(found.address, p.organization.hostname)) continue // skipped: wrong-company
checked.push(await ctx.step(`verify: ${p.$id}`, () => $.ContactPoint.verifyEmail({ address: found.address, maxCost: 0.005 })))
}
const reachable = checked.filter((c) => accept.includes(c.status))
const fresh = await ctx.step('dedupe', () => $.Collection.query({ minus: ['contacts', 'customers', 'suppressions', 'deals'], rows: reachable, key: 'email' }))
return ctx.step('save', () => $.Audience.create({ name: icp, members: fresh.rows.slice(0, size), from: ctx.action }))
},
})Extend it with extends: and base(), or compose your own from these steps.
Works with
Plays that order it: Account ads, Benchmark rows, Buying committee, Cold email, Show up in person, LinkedIn outbound, Proof-of-work outreach.
It serves Find your first customers, and runs in Hands-on. It deepens Outbound.
For agents
For agents: the guide, its approvals, retries and lookups
| Fact | Value |
|---|---|
| Guide | Audience.build |
| Guide approval | limits |
| Goal | Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | every.month(($) => $.Audience.refresh({ audience: |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | leaseline-audience-build-1 |
| Lookups | A find: input asks unless exactly one record fits |
| Step |
|---|
| 1. Count the market |
| 2. Find the companies and ask each site the fit question |
| 3. Find the people |
| 4. Find each work email |
| 5. Drop wrong-company addresses and find again |
| 6. Check each email |
| 7. Drop who you already know |
| 8. Save the Audience |
The amounts live once, on the hub (approvals).
Who does each step, and how it is checked
| Step | Function | Doer | Checker | Assurance | Inputs |
|---|---|---|---|---|---|
| 1. Count the market | code | Code | Code checks each result against its declared type | measured | $0.icp |
| 2. Find the companies and ask each site the fit question | code | Code | Code finds the quoted evidence on the page | measured | $0.icp, |
| 3. Find the people | code | Code | Code checks each result against its declared type | measured | $2.organizations, |
| 4. Find each work email | code | Code | Code checks each result against its declared type | measured | $3.prospects |
| 5. Drop wrong-company addresses and find again | code | Code | Code compares each address’s domain with the account’s own domain, after redirects, and keeps only a match | measured | $4.addresses, |
| 6. Check each email | code | Code | Code checks each result against its declared type | measured | $5.addresses |
| 7. Drop who you already know | code | Code | Code checks each result against its declared type | measured | $6.valid |
| 8. Save the Audience | code | Code | Code reads the result back from where it landed | measured | $7.rows, |
The doer is never the checker, and the weakest assurance on the path is the whole call’s.
Under the hood
Under the hood: the Systems, Nouns and Events it touches
| Kind | What it touches |
|---|---|
| Systems | Ads |
| Nouns | Organization, Prospect, Contact point, Collection, Audience |
| Events | Collection.queried, Audience.created |
Reference
Inputs, outputs, its Event and its errors
| Name | Type | Default | Meaning |
|---|---|---|---|
icp | string | Required | Your customer in one sentence: who they are, where, how big. Each phrase is a hard filter. |
question | string | Required | Asked of each company’s own site; returns fit and the quote that answers it. Leave it out to keep every match. |
titles | string[] | Required | The titles to reach at each company, in the buyer’s own words. |
size | integer | 100 | Checked emails wanted. It stops when it has them. |
perCompany | integer | 1 | People per company. Two reaches a second buyer when the first does not answer. |
accept | ('valid' | 'catch-all')[] | ['valid'] | Which check results count. A catch-all address goes in its own batch only when you add it here. |
maxCost | number | 2.00 | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a resume link. |
Emits Audience.built. Refuses with max-cost before it spends, and with the Data calls and Accounts groups (refusals).