Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.maximem.ai/llms.txt

Use this file to discover all available pages before exploring further.

Status: In Development · Playground demo coming soon. The recipe below is complete and runnable today — only the hosted playground showcase is pending.
A B2B SDR that runs structured outbound sequences while keeping a real picture of each prospect — what’s been said, what they replied to, what they ignored, and what they care about. Memory is per-prospect; the agent doesn’t restart from zero on every touch.

What you’ll build

An outbound SDR agent that:
  • Researches the prospect — company, role, recent signals
  • Drafts personalized first-touch — grounded in researched facts, not generic
  • Runs multi-touch sequences — email + LinkedIn DM + follow-up, with reply detection
  • Adapts on replies — interest, objection, unsubscribe, out-of-office
  • Books meetings when intent is detected
Est. build time: 90 minutes (most of it wiring email/LinkedIn/calendar tools).

When to use this recipe

Build this if:
  • You run cold outbound sequences and want them to feel less cold
  • You’ve got research data (your enrichment provider, company news, signal data) the agent should ground in
  • Multi-touch is the norm — a prospect sees the SDR over weeks, not minutes
  • You want the agent to learn what works for each persona over time

Architecture at a glance

The sequence orchestrator is dumb. The agent is smart. Memory is the bridge.

Stack

LayerChoice
Synap SDKmaximem-synap (Python) / @maximem/synap (TypeScript)
FrameworkOpenAI Agents SDK (Python) / Vercel AI SDK (TypeScript)
EmailPostmark / SendGrid / your transactional provider
LinkedInYour LinkedIn automation tool — must be compliant in your jurisdiction
CalendarCal.com / Google Calendar API for booking links
SchedulerCelery + Redis (Python) / BullMQ + Redis (TypeScript)
LLMOpenAI gpt-4o (drafting quality matters here)

Prerequisites

  • A Synap API key — see Authentication
  • Email sender domain + DKIM / SPF / DMARC set up
  • Enrichment data source (Clearbit, Apollo, Crunchbase — or your own CRM)
  • Python: Python 3.11+
  • TypeScript: Node 18+ and Python 3.11+ on the host
Cold outbound is regulated (CAN-SPAM, GDPR, CASL). Make sure your sender list is permissioned and every email has a working unsubscribe. The agent doesn’t enforce this — you do.

Install

pip install maximem-synap maximem-synap-openai-agents openai-agents celery redis

Build it

1. Identity & scoping

  • customer_id = "<your-company>"
  • user_id = <prospect ID> — your stable internal ID, NOT the email (people change emails)
  • conversation_id — one per prospect (long-running)
  • Metadata: account_id so you can roll up “everything on Acme Corp” across all prospects at that company

2. Research tools

from agents import function_tool

@function_tool
async def enrich_prospect(prospect_id: str) -> dict:
    """Fetch role, company, seniority, recent activity from your enrichment provider."""
    return await enrichment.lookup(prospect_id)

@function_tool
async def recent_signals(company_id: str) -> list[dict]:
    """Recent news, hires, funding, product launches at a company. Last 90 days."""
    return await signals.recent(company_id, days=90)

@function_tool
async def get_sequence_state(prospect_id: str) -> dict:
    """Where this prospect is in the sequence: current step, touches sent, replies."""
    return await sequences.state(prospect_id)

3. Action tools

@function_tool
async def draft_email(prospect_id: str, kind: str, context: dict) -> dict:
    """Return a draft. kind: 'first_touch' | 'follow_up' | 'objection_response' | 'book_meeting'."""
    return {"subject": "...", "body": "...", "draft_id": "..."}

@function_tool
async def send_email(draft_id: str) -> dict:
    """Send the previously-drafted email via your transactional provider."""
    return await email.send_draft(draft_id)

@function_tool
async def book_meeting(prospect_id: str, preferred_times: list[str]) -> dict:
    """Generate a Cal.com link or hold proposed slots on the rep's calendar."""
    return await calendar.book(prospect_id, preferred_times)

@function_tool
async def log_interaction(prospect_id: str, kind: str, details: dict) -> dict:
    """Log a touch into your CRM."""
    return await crm.log(prospect_id, kind, details)

4. The agent

SYSTEM = """You are an AI SDR. The user is a prospect or your own internal sequence orchestrator.

When asked to draft a touch:
- Always pull sequence_state, enrich_prospect, and recent_signals first.
- Use prior touches and replies from memory — never re-tread points the prospect ignored or rejected.
- Personalize on real facts. Generic "I see your company is growing" lines are forbidden.
- Match the prospect's prior tone if you have one. Otherwise, be direct, no buzzwords, one ask per email.

When a reply arrives, classify it: interested | objection | out_of_office | unsubscribe | not_now.
- interested → book a meeting via book_meeting.
- objection → store the objection in memory and draft a response addressing it.
- out_of_office → snooze the sequence.
- unsubscribe → kill the sequence immediately, log in CRM.
- not_now → snooze to the date they suggest, or 30 days default.

Always log_interaction with what you did and why."""

async def run_sdr(prospect_id: str, instruction: str) -> str:
    synap_search = create_search_tool(sdk=sdk, user_id=prospect_id, customer_id=CUSTOMER_ID)
    synap_store  = create_store_tool(sdk=sdk, user_id=prospect_id, customer_id=CUSTOMER_ID)

    agent = Agent(
        name="ai_sdr",
        instructions=SYSTEM,
        tools=[
            FunctionTool(synap_search, name_override="synap_search"),
            FunctionTool(synap_store,  name_override="synap_store"),
            enrich_prospect, recent_signals, get_sequence_state,
            draft_email, send_email, book_meeting, log_interaction,
        ],
    )
    result = await Runner.run(agent, input=instruction)
    return result.final_output

5. The orchestrator (scheduler)

The orchestrator is a thin cron / queue worker. It picks prospects whose next touch is due and asks the agent to handle it.
@celery.task
async def run_due_touches():
    due = await sequences.due_now()  # your DB query
    for prospect_id in due:
        await run_sdr(prospect_id, "It's time for the next touch. Decide and execute.")

6. Reply handling

Email reply webhooks (Postmark inbound, SES SNS, etc.) drop into a handler that ingests the reply and asks the agent to respond.
async def handle_email_reply(prospect_id: str, body: str, from_email: str):
    await sdk.memories.create(
        document=f"Prospect reply (from {from_email}):\n{body}",
        document_type="prospect-reply",
        user_id=prospect_id,
        customer_id=CUSTOMER_ID,
        metadata={"channel": "email", "direction": "inbound"},
    )
    await run_sdr(prospect_id, "The prospect replied. Read their message and decide.")

Run & verify

Touch 1 (first email)
[orchestrator triggers] → agent runs:
- enrich_prospect: "Maya Chen, VP Eng at Acme, joined 6 months ago"
- recent_signals: "Acme just shipped GA of their developer platform, June 4"
- draft_email(kind=first_touch, context={ angle: "developer-platform launch" })
- send_email
- log_interaction
Prospect replies (3 days later)
"Thanks but we're already using <competitor>. Not looking right now."

[reply webhook] → agent classifies: objection (competitor) + not_now
- synap_store: "Uses <competitor>. Not actively shopping as of [date]."
- draft_email(kind=objection_response, context={ objection: "uses competitor" })
- (or) snooze sequence to +60 days, depending on rules
Touch 5, three months later
[orchestrator triggers]
agent:
- synap_search: finds "uses <competitor>", "not actively shopping June '26"
- recent_signals: "Acme migrated off <competitor> last week (reported on HN)"
- draft_email referencing the migration — not the competitor by name, just the timing
- send_email
The agent didn’t restart from zero. It remembered the objection, watched for a signal, and re-engaged at the right moment.

Customize / extend

  • Salesforce CRM integration → tools wire into Salesforce; see Salesforce — Enterprise Sales Assistant for the read-side pattern.
  • LinkedIn channel → add send_linkedin_dm and linkedin_reply webhook. Same memory shape.
  • Reply review queue → for sensitive industries, don’t auto-send. Have the agent draft into a queue your humans approve.
  • Account-based marketing flavor → group prospects by account_id in metadata and have the agent coordinate touches across the buying committee.
  • Replay historical CRM activity → seed prospect memory with prior touches from your CRM at launch. See Patterns → Replay History.

Troubleshooting

Drafts feel generic
  • The agent isn’t pulling enrichment or signals before drafting. Sharpen the system prompt; require those tool calls.
  • Or your enrichment source is sparse — feed the agent more.
Agent re-pitches points the prospect already rejected
  • Memory ingestion of replies isn’t working, or synap_search isn’t called before drafting. Audit both.
Sequences fire too frequently
  • The orchestrator’s due-rules are the issue, not the agent. The agent should still see “last touch was 2 hours ago” in memory and refuse — add that check to the system prompt.
Unsubscribes not honored
  • The orchestrator must check unsubscribe state before each send. Don’t rely on the agent to remember — set a hard flag in your DB the worker checks first.