Skip to main content
operloom
Explore Operloom
Start a project ↗

Search Operloom

Start typing to explore the website.

    Automation

    Designing a prospecting agent for safe outreach

    Design a safe prospecting agent that sources contacts, personalises outreach, uses authenticated sending and prevents duplicate messages.

    Ahmed Khalil Ben Smida7 min read
    Designing a prospecting agent for safe outreach

    A prospecting agent is one of those projects that looks trivial in a demo and turns dangerous in production. The demo sends one clever, personalised email and everyone is impressed. Production sends the same person the same message three times because two runs overlapped, trips a spam filter because the sender was never authenticated, or quietly emails an address that unsubscribed last month. The difference between the two is not the AI; it is the engineering discipline around it.

    This guide lays out an architecture for a prospecting agent that behaves well: it sources contacts from a controlled list, personalises each message with a language model, sends through an authenticated channel, and, above all, never messages the same person twice. Every pattern here exists to make outreach safe, reproducible and defensible.

    Treat the contact list as the system of record

    The most reliable agents keep their state in one boring, auditable place: a structured contact list, whether a database table or a well-shaped spreadsheet. Each row is a person, and the columns carry both the input (name, company, email) and the state (whether they have been processed, and when). Making the list authoritative means the agent can crash, restart, or be re-run without ever losing track of who has already been contacted.

    The load step reads the list and returns only the rows that are still pending. That single filter, applied at the very start of every run, is the first line of defence against duplicate sends.

    def load_pending(rows):
        pending = []
        for i, row in enumerate(rows):
            # a row is pending only if it has an email and no "processed" mark
            if row.get("email") and not row.get("processed"):
                pending.append({"index": i, **row})
        return pending

    Keeping the processed flag on the same list as the data, rather than in a separate log, means the truth about who has been contacted lives in exactly one place. There is no second system to fall out of sync.

    Personalise with structure, not free improvisation

    An AI model will happily write a unique email for every prospect, but unbounded generation is how agents produce off-brand, inconsistent or occasionally embarrassing messages. The safer pattern is constrained personalisation: a fixed template that defines the structure and the guardrails, with the model filling only specific, bounded slots such as a relevant opening line tied to the prospect’s company.

    def personalise(model, contact):
        prompt = (
            "Write one opening sentence, under 25 words, referencing the "
            "prospect's company and industry. Professional, no hype, no emojis.\n"
            f"Company: {contact['company']}\nIndustry: {contact.get('industry','')}"
        )
        opener = model.complete(prompt).strip()
        return TEMPLATE.format(first=contact["firstname"], opener=opener)

    The template owns the greeting, the value proposition, the call to action and the signature; the model owns one sentence. This keeps every message on-brand and legally reviewable while still feeling written for the recipient. It also makes the output cheap to evaluate: you only have to check the one generated slot, not the whole email.

    Send through an authenticated channel

    Deliverability is decided long before you press send, at the DNS level. A prospecting agent that sends from a domain without proper SPF, DKIM and DMARC records will land in spam no matter how good the copy is. The sending code itself should be simple and go through an authenticated account or a reputable provider, never a raw connection from an unverified domain.

    def send(mailer, contact, subject, html):
        message = mailer.build(
            to=contact["email"],
            subject=subject,
            html=html,
            reply_to=SIGNATURE_EMAIL)
        result = mailer.send(message)          # provider handles auth + retries
        return result.accepted

    Two operational habits matter as much as the code. Warm up a new sending domain gradually rather than blasting a full list on day one, and keep volumes human. An agent that sends a measured stream looks like a person; one that sends a thousand identical messages in a minute looks like exactly what spam filters are built to catch.

    Mark sent immediately and idempotently

    The instant a send is accepted, write the processed mark back to the contact list. This is the step that makes the whole agent safe to re-run. If the process dies after this write, the next run skips the contact. If it dies before, the next run retries. Either way, the outcome is correct, because the mark and the send are kept as close together as possible.

    def process(contact, mailer, model, mark_done):
        subject, html = render(model, contact)
        if send(mailer, contact, subject, html):
            mark_done(contact["index"], when=now_iso())   # write state at once
            return True
        return False

    The mark_done callback writes back to whatever holds the list. Making it a small, single-purpose function keeps the critical “record that we contacted this person” step isolated and easy to reason about.

    Guard the run with rate limits and a kill switch

    An agent that can send is an agent that can misfire at scale, so every run needs brakes. Cap the number of messages per run and per day, insert a randomised delay between sends so the pattern looks human, and honour a simple kill switch that halts the run cleanly if something looks wrong. These guards cost almost nothing to build and prevent the failure mode that actually hurts: a runaway loop that contacts your entire list before anyone notices.

    • A per-run cap so a bug cannot empty the list in one go.
    • A per-recipient check so a retried row is never double sent.
    • A randomised delay between messages.
    • A kill flag the agent checks each iteration and obeys immediately.

    The line between prospecting and spamming is consent, and the agent must enforce it in code, not policy. Maintain a suppression list of anyone who has unsubscribed, bounced or complained, and check it before every send, not just at import. Honour the legal basis for contacting each prospect, keep the unsubscribe path working, and log every message sent with a timestamp and recipient so the whole campaign is auditable after the fact. An agent that cannot prove who it contacted, and why, is a liability regardless of how good its results look.

    def allowed(contact, suppression):
        email = contact["email"].lower()
        return (email not in suppression
                and contact.get("consent_basis")
                and "@" in email)

    Make it observable and reproducible

    Finally, the agent should be able to explain itself. Every run should emit the number of contacts loaded, personalised, sent, skipped and failed, and store the generated content alongside the recipient so a message can be reconstructed months later. Reproducibility is what turns an agent from a black box into a tool a team trusts: given the same list and the same templates, it should behave the same way every time, and any surprise in the numbers should point at a specific, diagnosable cause.

    Observability also closes the loop with the rest of your stack. When a prospect replies or converts, that outcome flows back to the CRM, where the same discipline of clean capture and event tracking lets you measure which segments and which openers actually produce meetings. The prospecting agent sources and sends; the CRM measures; and the two together become a system you can improve deliberately rather than guess at.

    Segment before you personalise

    Personalisation gets the credit, but segmentation does most of the work. A single message written for everyone, no matter how cleverly its opening line is generated, will underperform a message shaped for a specific segment. The agent should therefore treat the audience as a set of coherent groups rather than an undifferentiated list, and choose the template, the value proposition and even the call to action by segment. The generated slot then personalises within an already well-targeted message, which is where AI adds the most and risks the least.

    Good segments are usually built from a few stable attributes: the prospect’s industry, their rough company size, and the specific problem your offer solves for that type of buyer. Keep the number of segments small enough that each has a genuinely distinct message, because a dozen near-identical templates are harder to maintain than three sharply different ones. The discipline of writing a real, human template per segment also forces a useful question that pure automation lets you dodge: do you actually have something specific and valuable to say to this group, or are you just sending because you can?

    Measure replies, not sends

    The vanity metric of a prospecting agent is volume, and it is a trap. Sending more is easy and feels like progress, but the only numbers that matter are downstream: reply rate, positive-reply rate, and meetings booked. An agent optimised for sends will happily burn a list and a domain reputation to produce activity that converts to nothing. An agent measured on replies is forced to earn each one, which pushes you toward better segments, sharper templates and smaller, more deliberate volumes.

    Close the loop the same way the rest of a good stack does. When a prospect replies or books, that outcome should flow back to the contact record, tagged with the segment and the template that produced it. Over a few weeks this reveals which segments respond, which openers land, and which messages quietly fail, and it lets you retire what does not work instead of scaling it. Treated this way, a prospecting agent stops being a blunt instrument for sending more and becomes a controlled experiment in what your market actually responds to, which is the only version of outbound worth running.

    Key takeaways

    1. Keep the contact list as the system of record, with processing state on the same rows.
    2. Load only pending rows at the start of every run to prevent duplicate sends.
    3. Use constrained personalisation: a fixed template with the model filling one bounded slot.
    4. Send through an authenticated domain with SPF, DKIM and DMARC, and keep volumes human.
    5. Mark sent immediately and idempotently so the agent is always safe to re-run.
    6. Enforce suppression and consent in code, and log every message for audit.

    A prospecting agent lives or dies on discipline, not cleverness. Source from a controlled list, personalise within guardrails, send through an authenticated channel, and never contact the same person twice, and you have a tool that scales outreach without ever putting your domain or your reputation at risk.

    References

    Apply this to your business

    Marketing Automation and AI
    04Technology and infrastructure

    Automation and AI

    Documented, monitored automation that gives the team hours back and makes processes reliable.

    n8n workflows and AI-assisted systems that remove repetitive marketing work, connect your tools and keep humans in control of what ships.

    • n8n
    • Zapier
    • Make
    • OpenAI API
    View capability
    Growth Engine Building
    06Strategy and growth

    Growth engine

    One connected growth system with a single owner, measured end to end from first visit to revenue.

    The integrated engagement. Positioning, website, CRM, automation, analytics and demand generation built as one measurable system.

    • Astro
    • HubSpot
    • n8n
    • GA4
    View capability
    AI Systems, Applications and MCP Integrations
    03Technology and infrastructure

    AI Systems and Apps

    A governed AI system that can understand context, use approved tools, complete multi-step work and return a traceable result.

    Purpose-built AI applications that connect Claude and other models to approved business tools, data and workflows through MCP and secure APIs.

    • Claude
    • Model Context Protocol
    • Anthropic API
    • OpenAI API
    View capability

    OPERLOOM / LET’S BUILD WHAT’S NEXT

    Your next growth system.

    Four short steps to understand your project and prepare a personal reply.

    What would you like to change?

    Main objective
    Choose specific services (optional)