For agents: the plain-markdown version of this recipe is at /recipes/build-contact-list.md.
Build a contact list
A step-by-step recipe for an AI agent using the BaseBara connector. Follow it when the user wants a list of people to contact, either at a set of companies they already have or across all companies by role, size, and location. The list comes back stack-ranked: every row carries a tier and a one-line reason, so the user can see who to work first and why, and the natural next step is run_outreach_campaign on the top tier.
Tools used: find_contacts, get_contacts, verify_emails, list_searches, list_contact_events. Hands off to run_outreach_campaign.
Use when
- The user asks for "a list of VPs of Sales at these 15 companies", "decision-makers at mid-size SaaS in Texas", "the heads of HR at our target accounts", or "contacts for this campaign".
- The output is a ranked table of people, A tier first, with a reason per row and reveals only on the approved slice.
BaseBara is a people database, not a company database. It does not find companies for you. When the user has no company list and wants one, build it from their CRM export, a web search, or their own knowledge first, then come back here. When the user wants people across all companies by attributes, use company-less mode below.
Inputs to collect
Restate the brief as six lines before the first call:
- Who: the role, in words BaseBara can filter. Tier (Staff, Manager, Director, VP, C-Team), function, or literal title words.
- Where: a company list (names, domains, or ids), or company attributes for company-less mode (employee band, city, state).
- Hard vs soft: which constraints are must-have. "20+ employees" is hard. "Ideally in fintech" is soft.
- Size and use: how many contacts, and how they will be used. Cold single-thread: 1 per company. ABM: 3 to 5 per company spanning the committee.
- What makes a row A tier: the one or two signals that put a person at the top for this brief. Default to exact role fit plus a usable channel. The user may add one: new in role, a named skill, a company in the sweet spot of the size band, a warm path through a teammate. Write the tiering rule down before the first call so the ranking is not invented after the rows arrive.
- Reveal or not: whether the result should include the revealed emails and phones, or just the ranked list with the
Hascolumn showing what exists. Ask this up front, in the same message as the rest of the brief: "Do you want the emails and phones in the result (I will reveal the A tier), or just the ranked list for now?" Record the answer and the slice it covers (A tier by default, or whatever the user names). A user who wants a list to act on almost always wants it revealed; a user comparing segments usually does not. Do not make them look at a table of checkmarks and ask a second time.
If the brief is "lots of SaaS companies that might want X", that is not a brief. Ask for the segment, size band, and role.
Steps
1. Choose the mode
- Company-scoped: the user has companies. Pass up to 20 identifiers per call across
company_name,company_domain, andcompany_id.limitis the total rows per page in both modes, default and maximum 100. With several companies the page holds whole companies, smallest first, and splits one only at the page edge, so a later company can be missing from the first page entirely. Readcompanies[].total_matchedfor per-company depth and page withnext_cursorrather than guessing. Batch a long list into groups of 10 to 20. - Company-less: no companies. Requires at least one selective filter:
title_contains,job_function,authority_levels,city, orstate. Addmin_employees/max_employeesfor the size band. Returnshas_moreinstead of exact counts. Use it for "VPs of Sales at 200 to 1000 person companies in Austin".
2. Route each concept to the right filter
| Concept | Filter |
|---|---|
| Management tier | authority_levels |
| Department | job_function (substring of a 40-label vocabulary; "management" matches several labels) |
| Literal title words | title_contains (shortest distinctive stem, every word must appear, order free) |
| Founders, owners, partners | title_contains="founder" and so on. These are not a tier. |
| Background or expertise | skills_contains (sparse, about a quarter of contacts) |
| Needs a mobile or email | require_phone, require_work_email |
| Needs LinkedIn | has_linkedin=true |
| Company size | min_employees, max_employees (band-granular) |
An unrecognised job_function returns the full label list and runs no query. Pick from the list and re-call.
3. Run small first
Run the first call with limit 5 to see whether the filters hit the right people. Adjust before pulling the full set. In company-scoped mode, read the resolution block: fix ambiguous entries by re-calling with the chosen company_id, and list not_found identifiers back to the user instead of silently skipping them.
4. Pull the full set and tidy it
Run the remaining batches. Then, in your own reasoning, not with tool calls:
- Dedupe by
contact_id. - Re-apply hard thresholds. A soft size band is not proof of a hard threshold.
- Cap contacts per company to the number the use case needs. Rows arrive best-first by relevance within each company.
- Keep
last_eventon the rows that carry one. A teammate already revealed or contacted that person, and the user may not want to double-contact. Calllist_contact_eventswith thosecontact_idsif the user wants the history.
5. Tier every row and stack rank the list
Tiering is your reasoning over the rows, not a tool call. Use the rule you wrote down in the inputs. Every row gets a tier and a reason:
| Tier | Meaning | Typical signals |
|---|---|---|
| A | Work first. Fits the brief exactly and the user can reach them today. | Role matches the Who line (tier and function or title). Has LI or WE for the channel the user will use. Plus any booster the user named: recent_job_change, a skill from skills_contains, a warm path via last_event, company in the sweet spot of the size band. |
| B | Work second. Fits the brief with one gap. | Adjacent title or one tier off. Only one channel. Company at the edge of the size band. Long tenure in a role the brief wants new blood in. |
| C | Keep as fallback. Soft fit only. | Adjacent function. Two tiers off. No usable channel. The company's only match. |
| Skip | Do not contact without the user's say-so. | last_event shows a teammate contacted them recently, a reply or meeting is in progress, or the user said to exclude them. Keep the row and name the reason; do not silently drop it. |
Rank A, then B, then C, then Skip. Within a tier, put the rows with the most signals first; when two rows are equal, keep the tool's order, which is best-first by relevance within each company. When the per-company cap bites, the lowest-tiered rows at that company are the ones that go.
The Reason is one short phrase per row, at most about eight words, built only from signals visible in the row: "VP Sales, LI+WE, new in role 3 mo", "Dir RevOps, email only, 140 emp", "CRO, no email or LI", "Kev messaged Sep 12". The reason must be checkable from the table. A reason that could sit on any row ("good fit") is not a reason. These reasons are also the seed of the one checkable line per person that run_outreach_campaign asks for, so write them as facts, not opinions.
Say how many rows landed in each tier in one line above the table. If the A tier is empty, say so and offer to loosen the Who line or the size band before the user reveals anything.
6. Show the ranked list, then reveal the approved slice
Render the results as the find_contacts tool's Rendering guidance says. That guidance is the contract for the baseline; this recipe adds the ranking on top. In short: per-company summary lines, the tier count line, the Has legend line, then the Basebara table with its baseline columns (Company, Name, Title, Authority, Has, Location, Schools), then Tier and Reason as the first two extra columns to the right, then any filter you used as a further column, and the Activity column on the far right when team_events_present is true. Rows are in rank order from step 5, not in company order. A my_contacts table, when present, goes first with its own columns and is tiered the same way. Tier and Reason are the only columns this recipe adds; do not add others the guidance does not name. Show the count and has_more if capped.
Then act on the reveal answer from the brief:
- The user said yes: call
get_contactswith thecontact_idsof the approved slice in batches, without asking again, and show the revealed rows under the ranked table. If the slice is larger than the user expected (say, more A-tier rows than they asked for), tell them the count and confirm before revealing. Reveals need the user signed in; if the sign-in prompt fires, that is the only interruption. - The user said no, or the brief never settled it: show the ranked list and ask once which rows to reveal, suggesting the A tier as the default slice.
Never reveal the whole list by default. The approved slice is the one the user named, in the brief or after seeing the table.
When the list is for emailing or for export (a CSV, a CRM import, a sequencer), check the revealed addresses before handing them over. Each revealed row carries email_verification and email_verified_at. Call verify_emails with the addresses whose check is null, not deliverable, or older than about 30 days, up to 25 per call. It is free. Add a Verified column to the revealed table with the verdict and date. Leave undeliverable and bounced addresses out of the export, and say how many were risky so the user can decide on those. Skip this for a call sheet or a LinkedIn-only list.
7. Recommend the campaign as the next step
A ranked list is not the deliverable; the outreach is. End by recommending run_outreach_campaign on the A tier (and B if A is small), and say what carries over so the user does not re-brief:
- The A-tier rows, one per company for a cold campaign, as the list. The user's Who line is the persona set.
- Each row's Reason as the seed of that person's checkable line.
- The
request_idof each search, solist_searchesshows the campaign's source. - The Skip rows, so the campaign does not double-contact them.
Offer it in one sentence with a number: "Say go and I will run a campaign to the 12 A-tier people, starting with the first daily batch." When the user says go, call run_outreach_campaign and follow it from its inputs; it reuses this list instead of building a new one. If the user only wanted a handful of people, or one named person, reach_out is the right hand-off instead. A list meant for calls is a call sheet: reveal the approved slice and stop.
8. Make it repeatable
Every search is stored with its exact filters. list_searches lists them with returned and has_more per search, so the user can re-run a good search next week or see which filters came back empty. Mention this once at the end: "I can re-run this exact search later, or tighten the ones that hit the cap."
Output shape
LIST — <brief in one line>
Mode: company-scoped, 14 of 15 companies resolved (Acme Corp: ambiguous, 3 candidates shown below)
Returned 38 contacts, capped at 3 per company.
Tiers: 12 A · 15 B · 9 C · 2 skip (a teammate is already on them)
Has: WE work email · PE personal email · Ph business phone · LI LinkedIn
| Company | Name | Title | Authority | Has | Location | Schools | Tier | Reason | Activity |
| Acme | ... | VP Sales | VP | WE LI | Austin, TX | UT Austin | A | VP Sales, LI+WE, new in role 3 mo | |
| Globex | ... | Dir Sales Ops | Director | WE | ... | ... | B | one tier off, email only | |
| Initrode | ... | CRO | C-Team | | ... | ... | C | no email or LI | |
| Hooli | ... | VP Sales | VP | WE LI | ... | ... | Skip | Kev messaged Sep 12 | Kev, contacted, Sep 12 |
(Rows in rank order. Activity only when team_events_present is true; filter columns between Reason and Activity as the tool guidance says.)
Not found: "Initech" — check the spelling or give me the domain.
(When the brief said to reveal: the A-tier rows follow here with Email, Phone, and Verified columns, as get_contacts says to render them.)
Next: say "go" and I will run an outreach campaign to the 12 A-tier people, starting with the first daily batch; the reasons above become each person's opening line.
(When the brief said not to reveal: Next: say "reveal A" and I will reveal the 12 A-tier rows, then "go" for the campaign.)
Rules
- Companies first, then people. Never start with a broad people search when the user has a company list.
- Run small, then scale. The first call is a probe.
- Ask about revealing once, in the brief, not after the table. Never reveal the full list unprompted. Reveal the slice the user approved, in the brief or after seeing the table; suggest the A tier.
- Every row gets a tier and a reason. The reason is a fact from the row, under about eight words, never an opinion.
- Rank from the tiering rule you wrote down before the first call. Do not invent the rule after the rows arrive.
- Skip rows stay in the table with their reason. Dropping a person silently is how teams double-contact.
- End with the campaign. The next step after a ranked list is
run_outreach_campaignon the A tier, offered in one sentence with the count. - Never treat a size band as a hard threshold. Re-check against the user's number.
- Surface
ambiguousandnot_foundcompanies. Do not pick a candidate for the user. - Name-only searches are not supported across all companies. Combine a name with a company or a selective filter.
- Render the table the way
find_contactssays to. When this recipe and the tool guidance disagree, the tool guidance wins. - Nothing is logged in this recipe. Events start when the user contacts someone, in
reach_outandrun_outreach_campaign. - Verify before an email export. Never export an
undeliverableorbouncedaddress.