For agents: the plain-markdown version of this recipe is at /recipes/outreach-campaign.md.

Run an outreach campaign

A step-by-step recipe for an AI agent using the BaseBara connector. Follow it when the user wants to reach many people over days or weeks, not one person today. It builds on the reach-out recipe: the craft of each message is the same, and this recipe adds how to slice the list, how to make every message its own, and how to pace the sends yourself so the campaign reads like a person working a list, not a script firing at it. It works the same on LinkedIn, by email, or as a mix; only the send step differs.

Tools used: find_contacts, get_contacts, verify_emails, list_searches, get_linkedin_messages, send_linkedin_connection_request, send_linkedin_message, create_contacts, log_contact_event, list_contact_events.

Use when

  • The user says "campaign", "sequence", "reach out to all of these", "work this list", "message these 30 people", or hands you a list and an offer.
  • There are more than about five recipients, or the sends will span more than one sitting.

Not for: one named person or account (call reach_out), or a reply to someone who already answered (call follow_up).

The two rules that make this a campaign and not spam

1. Every message is written for its recipient. One angle per persona, one checkable line per person, written out in full each time. If two messages in the campaign are identical, the campaign is wrong. On LinkedIn the server also refuses the same text to more than a few people a day, but you should never find out where that line is.

2. You pace the sends. The server does not pace them for you. On LinkedIn it has hard caps to protect the user's account, and hitting them is a failure of this recipe, not a feature of it. On email there is no server at all, only the user's own domain reputation, and the same discipline applies. Plan a daily batch far below any cap, send it with gaps, stop when the batch is done, and resume tomorrow. A refusal from the server means you planned badly. Report it and stop for the day.

Inputs to collect

Restate the campaign brief before any tool call:

  • The slice: who gets this campaign, defined by one real, shared thing. Same role at the same kind of company, the same trigger in the same window, the same tool in the stack. If the copy would read the same sent to a different slice, the slice is too loose. Tighten it.
  • The offer and the ask: one concrete thing the user is offering, and the smallest real action they want back this week.
  • The channel: LinkedIn, email, or a split by persona. BaseBara sends LinkedIn messages and connection requests itself. For email it reveals the addresses; the sends go through a mail connector in the same chat (Gmail, Outlook, a CRM's mail tool) when the user has one, otherwise from the user's own client, at the same pace, with the same per-person writing either way. Phone is not a campaign channel: a list of calls is a call sheet, which build_contact_list produces. If the user has no preference, LinkedIn for people who are already connected or who the user wants a relationship with, email for a message that needs a proof point or a link.
  • The size and the horizon: how many people, and how many days the user is willing to spend. A 30-person campaign is a two- to three-week piece of work on any channel, not an afternoon.
  • The persona set: the 1 to 3 roles on the list. Each gets its own angle.

Steps

1. Build or confirm the list

If the user has a list, use it. If not, call build_contact_list and follow it: find_contacts company-scoped or company-less, probe small, tidy, and show the table. Prefer rows that have both a LinkedIn profile and a work email: the day-3 follow-up goes on the other channel, so a person with only one has a shorter cadence. Filter to a single channel (has_linkedin=true or require_work_email=true) only when the user insists on one. Cap at one person per company for a cold campaign. Read last_event on the rows (the Activity column) and ask the user whether to skip people a teammate is already working.

When the list came from build_contact_list in this conversation, reuse it instead of searching again: the A tier is the campaign, B tier joins when A is small, and the Skip rows stay out. Each row's Reason is the seed of that person's checkable line in step 4.

Keep the request_id of each search. list_searches will show these later as the campaign's source.

Verify the email rows once, up front. After the user approves the list and the channel split, call get_contacts for everyone whose first touch or day-3 touch is email, and read email_verification and email_verified_at on each row. Collect the addresses where the check is null, not deliverable, or older than about 30 days, and call verify_emails with them, up to 25 per call. It is free. Then:

  • undeliverable or bounced: drop the address. Move the person to LinkedIn if they have it, otherwise out of the campaign. Say who.
  • risky: keep them, but tell the user how many and let them decide whether risky addresses get email or LinkedIn.
  • not_checked (unknown with a null verified_at): call verify_emails again for those before their sitting.

2. Write the persona-angle matrix

Before drafting a single message, write a short table and show it to the user:

Persona Channel What they care about The angle Proof point (one, attributed)
VP Sales LinkedIn pipeline coverage, rep ramp ... none on LinkedIn
RevOps lead email tooling sprawl, data quality ... ...

Then one message skeleton per persona, following the reach-out drafting rules: implication-led opener, one detail, one proof point at most (none on LinkedIn), one ask, 60 to 110 words for email, well under 500 characters for a DM. Get the user's approval on the matrix and the skeletons. This is the only approval that covers many people at once.

3. Plan the daily batch

Set the batch from the horizon, not from the caps. Defaults when the user has no preference:

Action Per day Why
LinkedIn connection requests to new people 8 to 12 Requests are the fastest way to get an account restricted. Half the server's cap, spread over the day.
LinkedIn DMs (to connected people or accepted requests) 10 to 15, in two or three sittings Each one is written fresh and read back. More than this and the writing degrades.
Emails, sent by the user from their own client 15 to 25 Past this the writing degrades and the domain starts to look like a sender, not a person.
Gap between any two sends, on any channel 2 to 5 minutes of the user's time, never back to back A person does not send two messages in twenty seconds.

Write the plan as a schedule the user can see: "Day 1: 10 connection requests to the VP Sales persona, 15 emails to RevOps. Day 2: 10 more requests, plus DMs to anyone who accepted. Day 4: first follow-ups, on the other channel from each person's first touch." Each day is a separate sitting with the user. You cannot sleep between sends, so the plan is what carries the campaign across days.

4. Each sitting: send the day's batch, one person at a time

For each recipient in today's batch, in order:

  1. Check for an existing conversation. On LinkedIn, get_linkedin_messages with their linkedin_url: if there is a thread, pull them out of the campaign and offer follow_up; if a request is pending, skip them today; if they are connected, they get a DM, if not, a connection request. On email, read last_event and, when it shows a touch, list_contact_events: anyone the user or a teammate already wrote to leaves the campaign the same way.
  2. Write the message from the persona skeleton, adding the one line that is specific and checkable for this person: their title and company read from the contact row, a recent_job_change, a shared school, something they published that the user pointed at. Never a line that would be true of the next person on the list.
  3. Run the reach-out final gate on it. Delete the opener and see if it still stands. No "worth". No event in sentence one. Could this go to anyone else today? Then rewrite.
  4. Show the text and send only after the user approves it. A one-word "go" per message is fine once the matrix is approved; silence is not.
  5. Send on the recipient's channel. LinkedIn: send_linkedin_message or send_linkedin_connection_request. The server saves the person and logs the contacted event itself, with the message or the request's note as its text, so follow_up can find the requests that get accepted; do not log it again. Follow next_step only when the answer says event_logged false. Email: get_contacts for the address (verified in step 1; if this person was added since, check them the same way first), then send the approved text from the mail connector if one is in the chat, or hand the address and text to the user. When the connector confirms or the user says it went out, create_contacts if needed and log_contact_event kind contacted channel email. A mail connector does not change the pace: one send, one gap, the next person. Record the result in the tracker.
  6. Move to the next person. Do not fire the next send in the same breath. Let the user's reading time be the gap.

Stop when the batch is done, even if the user says "keep going". Say that today's batch is complete, what the plan says for tomorrow, and that stretching it is how accounts and domains get restricted.

5. Keep the tracker

Maintain one table in the conversation and reprint it at the end of every sitting:

Name Company Persona Channel Day sent Action Status Next
... ... VP Sales LinkedIn 1 request pending check day 3
... ... RevOps email 2 email sent follow-up day 5 if silent

Status on LinkedIn comes from get_linkedin_messages: invite_pending, connected with a reply, connected with silence. Status on email comes from the mail connector when one is in the chat (search for replies and bounces from the campaign's addresses), otherwise from the user: replied, bounced, or silent. The tracker is the campaign's view; the contact events are its record. Every send is logged as contacted and every reply as replied, each with its channel, so begin each sitting with list_contact_events (kinds contacted, replied, meeting, connected, connection_lapsed; the last two are the server's own record of requests accepted or gone dead) to rebuild the tracker, then a pass of get_linkedin_messages over the active LinkedIn names for status, and a search of the mail connector, or one question to the user, about what came back by email. Ask the user to paste yesterday's tracker only when the events do not cover it.

6. Follow-ups, and what to do with replies

  • Accepted a request: the DM goes out in the next sitting, written fresh, never "thanks for connecting". follow_up, run over the campaign at the start of a sitting, finds everyone who accepted since the last one.
  • Replied, on any channel: log replied with that channel and the gist of what they said, then pull them out of the campaign. A reply is now a conversation, and campaign cadence must not touch it. follow_up handles it, and handles the whole campaign at once: run it over the slice and window at the start of a sitting, and it builds the queue of replies, accepted requests, and silences from the log.
  • Booked a call: log meeting.
  • Silent after 3 or more days: one follow-up on a different channel from the first touch (email after a silent DM, LinkedIn after a silent email), shorter than the first, with a new angle or asset, standing alone. Never the same text as anyone else's follow-up. After the second silence, a 25 to 50 word close that leaves the door open, then stop.
  • Request not accepted after 3 days: leave it pending, and send the day-3 touch by email without mentioning the request. If they accept later, the DM continues from wherever the conversation got to.
  • Bounced: log_contact_event kind bounced with the address in the text. That marks the address undeliverable for everyone, so no teammate tries it. Move to the other channel right away if the person has it.
  • Channel rotation is per touch, not a second campaign: one message per person per touch, on one channel at a time. Never the same person on two channels in the same sitting.

7. Tighten after each wave

After the first 10 to 15 sends have had three days to land, read the tracker with the user. Which persona replied, which angle died, which channel pulled, which slice drifted. Change one thing before the next wave: a narrower slice, a retired angle, a different ask, a channel swap for one persona. A campaign that does not tighten becomes spam with good grammar.

Output shape

End of a sitting:

CAMPAIGN  <slice in one line>   Day 2 of ~12

TODAY      8 connection requests sent (VP Sales ×5, RevOps ×3)
           3 DMs sent to people who accepted yesterday
           12 emails handed over, 12 confirmed sent
           0 refusals

REPLIES    1 — Dana Ruiz (RevOps, Acme), email: "send me the comparison" → moved to follow-up

TOMORROW   10 requests to the remaining VP Sales rows; DMs to any new accepts; 15 emails.
           First follow-ups start day 4.

TRACKER    (table)

Rules

  • One message per recipient, written for them. Identical text to two people is a defect, on any channel.
  • You own the pace. Plan the batch below the server's limits and the user's domain's tolerance, and stop when it is done. A server refusal is a planning failure. Report it and end the sitting.
  • Never send without the user seeing the text. The approved matrix covers the approach, not the words.
  • Never fire two sends back to back, on any channel. The user's reading time between messages is the minimum gap.
  • A reply ends campaign treatment for that person. Hand them to follow_up.
  • Three days of silence on a first touch, including a connection request, moves the next touch to the person's other channel. Never the same person on two channels in the same sitting.
  • Email sends: BaseBara reveals the addresses, and verify_emails checks them once before the first sitting. An undeliverable or bounced address never gets a send. A mail connector in the chat sends them after approval, or the user does from their own client, at the same pace, with the same per-person writing. Log contacted with channel email for each one that is confirmed sent.
  • Tighten the slice or retire an angle after each wave. Do not loosen the list to feed volume.
  • Every send and every reply is logged with its channel: LinkedIn sends by the server itself, email sends and every reply by you with log_contact_event. The tracker in the chat is a view; the events are what the team sees.