outreach

AI Agent Integration Guide for outreach-send-core

This file exists to make the package implementation path explicit for coding agents.

Goal

Integrate outreach-send-core by:

Do not rewrite the engine logic unless you are intentionally modifying package behavior. The intended extension points are:

Fastest Correct Path

  1. Implement OutreachRepository.
  2. Instantiate createOutreachEngine(...).
  3. Pass a mailer, link builder, and template builder.
  4. Optionally pass an email verifier and verification evaluator.
  5. Instantiate GoogleFirstSeedProvider(...) for production discovery.
  6. Call provider.search(criteria) from your discovery job or admin action.
  7. Import only result.seeds into the send pool.
  8. Surface result.reviewCandidates in review UX, but do not auto-send or auto-approve them.
  9. Call sendApprovedBatch(campaignId) from a job, queue worker, or admin action.

Required Repository Contract

You must implement all of these methods exactly:

Required Shapes

OutreachCampaign

{
  id: string
  name?: string | null
  dailySendCap: number
}

OutreachSendCandidate

{
  prospect: {
    id: string
    campaignId: string
    companyName: string
    businessType?: string | null
    businessSummary?: string | null
  }
  contact?: {
    id: string
    email: string
    normalizedEmail: string
    name?: string | null
  } | null
  preview?: {
    id: string
    publicUrl?: string | null
  } | null
}

Non-Negotiable Invariants

Canonical Engine Algorithm

When an agent calls sendApprovedBatch(campaignId), the package does this:

  1. Load the campaign with getCampaign(campaignId).
  2. In parallel: countSentSince(campaignId, startOfToday) resolveGlobalDailyCap(campaign)
  3. Compute: campaignDailyCap = floor(max(campaign.dailySendCap, 0)) globalDailyCap = floor(max(configuredOrOverrideCap, 0)) dailyCap = min(campaignDailyCap, globalDailyCap) remainingDailyCap = max(dailyCap - sentTodayCount, 0)
  4. If remainingDailyCap === 0, return immediately with all send counts at 0.
  5. Fetch raw candidates with listApprovedSendCandidates(campaignId, remainingDailyCap).
  6. Set approvedCount = rawCandidates.length.
  7. Filter candidates: if contact is missing, increment skippedMissingContactCount if preview.publicUrl is missing, increment skippedMissingPreviewCount otherwise keep the candidate for send processing
  8. Query existing suppressions with getSuppressedEmailSet(...) using the kept candidates’ normalizedEmail values.
  9. If an EmailVerifier exists and isEnabled() is true, verify all non-suppressed emails in one batch.
  10. If the verifier throws, the engine blocks sends for unsuppressed candidates and records them as failed.
  11. For each kept candidate, increment attemptedCount.
  12. Build a fallback subject before any suppression or send outcome is recorded.
  13. If the candidate email is already suppressed: call recordSuppressedSend(...)
  14. Else if verification is enabled but unavailable or missing for the candidate: call recordFailedSend(...)
  15. Else if verification returns a result and the evaluator says sendable: false: optionally call suppressEmail(...) call recordSuppressedSend(...)
  16. Else: build links resolve reply-to build the final template call mailer.send(...) call recordSentSend(...)
  17. If mailer.send(...) throws: call recordFailedSend(...)
  18. Return the aggregate SendBatchResult.

Meaning of Result Counters

Method-by-Method Semantics

getCampaign

Must return the campaign record used for caps and email context.

Minimum requirement:

countSentSince

Use your send log table or equivalent source of truth.

Correct behavior:

listApprovedSendCandidates

Return prospects approved for outreach in the order you want them processed.

Recommended behavior:

It is acceptable to return candidates with missing contact or preview, but the engine will skip them and track that in the result counters.

getSuppressedEmailSet

This is a membership query, not a full export. Given a list of normalized emails, return a set containing only the ones that are suppressed.

suppressEmail

Persist a durable suppression record.

Recommended fields to store:

recordSuppressedSend

Persist an outreach attempt that was intentionally not sent because the recipient was suppressed or verification blocked the send.

recordFailedSend

Persist an outreach attempt that should have sent but failed because verification was unavailable, the verifier returned no result, or the mailer threw an error.

recordSentSend

Persist a successful outbound send.

Recommended fields to store:

Minimal Adapter Skeleton

import type {
  OutreachCampaign,
  OutreachRepository,
  OutreachSendCandidate,
  OutreachSuppressionInput,
  RecordFailedSendInput,
  RecordSentSendInput,
  RecordSuppressedSendInput,
} from 'outreach-send-core'

export class AppOutreachRepository implements OutreachRepository {
  async getCampaign(campaignId: string): Promise<OutreachCampaign> {
    // Query campaign table and return dailySendCap.
  }

  async countSentSince(campaignId: string, since: Date): Promise<number> {
    // Count successful sends for this campaign where sentAt >= since.
  }

  async listApprovedSendCandidates(
    campaignId: string,
    limit: number,
  ): Promise<OutreachSendCandidate[]> {
    // Return approved prospects plus contact and preview data.
  }

  async getSuppressedEmailSet(normalizedEmails: string[]): Promise<Set<string>> {
    // Query suppressions WHERE normalized_email IN (...)
  }

  async suppressEmail(input: OutreachSuppressionInput): Promise<void> {
    // Insert durable suppression record.
  }

  async recordSuppressedSend(input: RecordSuppressedSendInput): Promise<void> {
    // Insert send log row with status = 'suppressed'.
  }

  async recordFailedSend(input: RecordFailedSendInput): Promise<void> {
    // Insert send log row with status = 'failed'.
  }

  async recordSentSend(input: RecordSentSendInput): Promise<void> {
    // Insert send log row with status = 'sent'.
  }
}

Minimal Engine Wiring

import {
  BrevoOutboundMailer,
  BouncerEmailVerifier,
  createOutreachEngine,
  evaluateBouncerEmailVerification,
} from 'outreach-send-core'

const engine = createOutreachEngine({
  repository: new AppOutreachRepository(),
  mailer: new BrevoOutboundMailer({
    apiKey: process.env.BREVO_API_KEY,
    fromEmail: process.env.EMAIL_FROM,
    fromName: process.env.EMAIL_FROM_NAME,
  }),
  emailVerifier: new BouncerEmailVerifier({
    apiKey: process.env.BOUNCER_API_KEY,
  }),
  evaluateEmailVerification: evaluateBouncerEmailVerification,
  resolveGlobalDailyCap: async () => 50,
  resolveReplyTo: async () => 'hello@example.com',
  linkBuilder: async ({ campaign, contact, preview }) => ({
    previewUrl: preview.publicUrl,
    unsubscribeUrl: `https://example.com/unsubscribe?email=${encodeURIComponent(contact.email)}`,
    registerUrl: `https://example.com/register?campaign=${campaign.id}`,
  }),
  templateBuilder: async ({ prospect, links }) => ({
    subject: `Preview for ${prospect.companyName}`,
    htmlContent: `<a href="${links.previewUrl}">Open preview</a>`,
    textContent: `Open preview: ${links.previewUrl}`,
    tags: ['outreach'],
  }),
})

await engine.sendApprovedBatch('campaign_123')

What Not To Do

Use GoogleFirstSeedProvider for production discovery.

Fastest Correct Path

import {
  GoogleFirstSeedProvider,
  type ProspectSeedProvider,
  type SearchCriteria,
} from 'outreach-send-core'

const provider: ProspectSeedProvider<string> = new GoogleFirstSeedProvider({
  apiKey: process.env.GOOGLE_PLACES_API_KEY,
  publicServiceUserAgent: process.env.PUBLIC_SERVICE_USER_AGENT,
  publicServiceReferer: process.env.PUBLIC_SERVICE_REFERER,
  maxExpandedMunicipalities: 12,
  searchBudgetMs: 120000,
  crawlPageLimit: 5,
  maxEnrichmentCandidates: 100,
})

const result = await provider.search({
  country: 'IT',
  language: 'it',
  cities: ['Milano'],
  provinces: [],
  businessTypes: ['clothing_store'],
  keywords: [],
  limit: 100,
})

const acceptedSeeds = result.seeds
const reviewQueue = result.reviewCandidates ?? []

Discovery Contract

GoogleFirstSeedProvider.search(criteria) returns:

Production Invariants

Meaning Of Discovery Output

Expected Production Behavior

Legacy Multi-Source Search Notes

The bundled GooglePlacesSeedProvider remains available as a province-aware multi-source finder while keeping the same search(criteria) entrypoint.

Important behavior:

Use this class only if you are intentionally working on the older multi-source finder. Do not make it the default production discovery provider for new integrations.

Acceptance Checklist

An implementation is correct if all of these are true:

Source Of Truth

If this file and code ever disagree, the code in src/engine.ts and src/types.ts is the source of truth.