> ## 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.

# AI SDR

> Outbound B2B prospecting agent: research, personalize, sequence, book, with prospect memory across touches.

<Info>
  **Status:** In Development · Playground demo coming soon.
  The recipe below is complete and runnable today; only the hosted playground showcase is pending.
</Info>

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

```mermaid theme={null}
flowchart TD
    Sched[Sequence orchestrator<br/>cron / queue per prospect] --> Due[For each due touch]
    Due --> Fetch[(Synap context fetch<br/>prior touches, replies, objections)]
    Due --> Research[Research tools<br/>fresh enrichment, recent signals]
    Fetch --> Draft[LLM drafts message]
    Research --> Draft
    Draft --> Review[Review queue<br/>optional]
    Draft --> Send[Send via email / LinkedIn]
    Send -.-> Ingest1[(Synap ingest: outbound)]
    Reply[Inbound reply] --> Webhook[Webhook]
    Webhook --> Ingest2[(Synap ingest: inbound)]
    Ingest2 --> Classify[Classify intent<br/>adjust sequence:<br/>book / objection / nurture / kill]
```

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

## Stack

| Layer         | Choice                                                                                                                |
| ------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Synap SDK** | `maximem-synap` (Python) / `@maximem/synap-js-sdk` (TypeScript)                                                       |
| **Framework** | [OpenAI Agents SDK](/integrations/openai-agents) (Python) / [Vercel AI SDK](/integrations/vercel-ai-sdk) (TypeScript) |
| **Email**     | Postmark / SendGrid / your transactional provider                                                                     |
| **LinkedIn**  | Your LinkedIn automation tool, must be compliant in your jurisdiction                                                 |
| **Calendar**  | Cal.com / Google Calendar API for booking links                                                                       |
| **Scheduler** | Celery + Redis (Python) / BullMQ + Redis (TypeScript)                                                                 |
| **LLM**       | OpenAI `gpt-4o` (drafting quality matters here)                                                                       |

## Prerequisites

* A Synap API key, see [Authentication](/setup/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

<Warning>
  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.
</Warning>

### Install

<CodeGroup>
  ```bash Python theme={null}
  pip install maximem-synap maximem-synap-openai-agents openai-agents celery redis
  ```

  ```bash uv theme={null}
  uv add maximem-synap maximem-synap-openai-agents openai-agents celery redis
  # pip-compatible (existing venv): uv pip install maximem-synap maximem-synap-openai-agents openai-agents celery redis
  ```

  ```bash TypeScript theme={null}
  npm install @maximem/synap-js-sdk @maximem/synap-vercel-adk ai @ai-sdk/openai bullmq ioredis zod
  ```
</CodeGroup>

## 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

<Note>
  `conversation_id`, `user_id`, and `customer_id` must be valid UUIDs. Generate the per-prospect id with `str(uuid.uuid4())` (Python) or `crypto.randomUUID()` (JS); map any non-UUID internal id to a deterministic UUID with `uuid.uuid5(...)`.
</Note>

### 2. Research tools

<CodeGroup>
  ```python Python theme={null}
  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)
  ```

  ```typescript TypeScript theme={null}
  const researchTools = {
    enrich_prospect: tool({
      description: "Fetch role, company, seniority, recent activity.",
      parameters: z.object({ prospectId: z.string() }),
      execute: async ({ prospectId }) => enrichment.lookup(prospectId),
    }),
    recent_signals: tool({
      description: "Recent news, hires, funding, product launches at a company.",
      parameters: z.object({ companyId: z.string() }),
      execute: async ({ companyId }) => signals.recent(companyId, 90),
    }),
    get_sequence_state: tool({
      description: "Where this prospect is in the sequence.",
      parameters: z.object({ prospectId: z.string() }),
      execute: async ({ prospectId }) => sequences.state(prospectId),
    }),
  };
  ```
</CodeGroup>

### 3. Action tools

<CodeGroup>
  ```python Python theme={null}
  @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)
  ```

  ```typescript TypeScript theme={null}
  const actionTools = {
    draft_email: tool({
      description: "Draft an email. kind: 'first_touch'|'follow_up'|'objection_response'|'book_meeting'.",
      parameters: z.object({
        prospectId: z.string(), kind: z.string(), context: z.record(z.any()),
      }),
      execute: async ({ prospectId, kind, context }) =>
        drafter.compose(prospectId, kind, context),
    }),
    send_email: tool({
      description: "Send a drafted email.",
      parameters: z.object({ draftId: z.string() }),
      execute: async ({ draftId }) => emailProvider.sendDraft(draftId),
    }),
    book_meeting: tool({
      description: "Generate a booking link or hold slots.",
      parameters: z.object({
        prospectId: z.string(), preferredTimes: z.array(z.string()),
      }),
      execute: async ({ prospectId, preferredTimes }) => calendar.book(prospectId, preferredTimes),
    }),
    log_interaction: tool({
      description: "Log a touch into the CRM.",
      parameters: z.object({
        prospectId: z.string(), kind: z.string(), details: z.record(z.any()),
      }),
      execute: async ({ prospectId, kind, details }) => crm.log(prospectId, kind, details),
    }),
  };
  ```
</CodeGroup>

### 4. The agent

<CodeGroup>
  ```python Python theme={null}
  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
  ```

  ```typescript TypeScript theme={null}
  const SYSTEM = `You are an AI SDR.

  When drafting:
  - Always pull sequence_state, enrich_prospect, recent_signals.
  - Use prior touches and replies from memory. Never re-tread rejected points.
  - Personalize on real facts. No generic openers.
  - One ask per email.

  When a reply comes in, classify: interested | objection | out_of_office | unsubscribe | not_now.
  - interested → book_meeting.
  - objection → store memory + draft response.
  - out_of_office → snooze.
  - unsubscribe → kill sequence + log.
  - not_now → snooze 30 days or to suggested date.

  Always log_interaction.`;

  export async function runSdr(prospectId: string, instruction: string): Promise<string> {
    const model = synap.wrap(openai("gpt-4o"), {
      userId: prospectId,
      customerId: CUSTOMER_ID,
      conversationId: prospectId,
    });

    const { text } = await generateText({
      model,
      system: SYSTEM,
      prompt: instruction,
      tools: { ...researchTools, ...actionTools },
    });
    return text;
  }
  ```
</CodeGroup>

### 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.

<CodeGroup>
  ```python Python theme={null}
  @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.")
  ```

  ```typescript TypeScript theme={null}
  // Cron job or BullMQ scheduled worker
  async function runDueTouches() {
    const due = await sequences.dueNow();
    for (const prospectId of due) {
      await runSdr(prospectId, "It's time for the next touch. Decide and execute.");
    }
  }
  ```
</CodeGroup>

### 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.

<CodeGroup>
  ```python Python theme={null}
  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.")
  ```

  ```typescript TypeScript theme={null}
  export async function handleEmailReply(
    prospectId: string,
    body: string,
    fromEmail: string,
  ) {
    await synap.sdk.memories.create({
      document: `Prospect reply (from ${fromEmail}):\n${body}`,
      documentType: "prospect-reply",
      userId: prospectId,
      customerId: CUSTOMER_ID,
      metadata: { channel: "email", direction: "inbound" },
    });
    await runSdr(prospectId, "The prospect replied. Read their message and decide.");
  }
  ```
</CodeGroup>

## Run & verify

```text Touch 1 (first email) theme={null}
[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
```

```text Prospect replies (3 days later) theme={null}
"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
```

```text Touch 5, three months later theme={null}
[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](/cookbook/b2b-salesforce) 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](/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.

## Related

* **Integrations:** [OpenAI Agents SDK](/integrations/openai-agents) · [Vercel AI SDK](/integrations/vercel-ai-sdk)
* **Concepts:** [Customer Context](/concepts/context-end-to-end#customer-context) · [Long-term Context](/concepts/context-end-to-end#long-term-context) · [Memory Scopes](/concepts/memory-scopes)
* **Patterns:** [Replay History](/patterns/replay-history) · [Multi-Tenant SaaS](/patterns/multi-tenant-saas)
* **Guides:** [Multi-User Memory Scoping](/guides/multi-user-scoping)
* **Other recipes:** [Salesforce: Enterprise Sales Assistant](/cookbook/b2b-salesforce) · [Tier Escalation](/cookbook/support-tier-escalation)
