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

# Salesforce: Enterprise Sales Assistant

> Account-grounded sales assistant with opportunity history and CRM-aware recommendations.

<Check>
  **Status:** Live in Playground · **Try it:** [synap.maximem.ai/playground](https://synap.maximem.ai/playground)
  Open the playground and pick **Salesforce: Enterprise Sales Assistant** to see the reference implementation running.
</Check>

A sales-rep-facing assistant that lives next to Salesforce. It knows the rep's accounts, open opportunities, the deal narrative across recent activity, and what the rep has tried before. It drafts outreach, prepares for calls, logs activity, and updates the CRM, all while keeping the rep's tone and territory context across sessions.

## What you'll build

A chat agent for sales reps that:

* **Pulls live CRM state**: accounts, opps, contacts, recent activity
* **Remembers rep context**: territory, ICP, tone of voice, deal-specific narratives
* **Drafts outreach** in the rep's voice, grounded in account history
* **Updates Salesforce**: log calls, advance opp stages, create follow-up tasks

**Est. build time:** 45 to 60 minutes (most of that is Salesforce auth + field mapping).

## When to use this recipe

Build this if:

* Your reps work out of Salesforce and want an assistant that already knows their book of business
* You want per-rep tone/persona memory (so drafts sound like the rep, not a generic LLM)
* You need bi-directional CRM I/O (read accounts, write activities)
* Account narratives span weeks and need to persist across sessions

## Architecture at a glance

```mermaid theme={null}
flowchart TD
    Chat[Sales rep chat<br/>sidebar / Slack / web] --> Backend[Your backend]
    Backend -->|fetch| Synap1[(Synap context fetch<br/>rep tone, territory, account narratives, prior drafts)]
    Synap1 --> LLM[LLM with tools]
    LLM --> Tools["get_account<br/>get_opportunities<br/>get_contacts<br/>query_pipeline<br/>log_activity<br/>update_opportunity<br/>create_task"]
    Tools <--> SF[(Salesforce REST)]
    Tools --> Reply[Reply to rep]
    Reply -.->|fire-and-forget| Synap2[(Synap ingest turn)]
```

The rep is the `user_id`; the rep's org is the `customer_id`. Account-specific notes get tagged in metadata so they're recallable per-account.

## 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) |
| **CRM**       | Salesforce REST + jsforce (Node) or simple-salesforce (Python)                                                        |
| **LLM**       | OpenAI `gpt-4o`                                                                                                       |
| **Channel**   | Sidebar widget / Slack DM / web chat, choose what fits your reps                                                      |

## Prerequisites

* A Synap API key, see [Authentication](/setup/authentication)
* A Salesforce Connected App with OAuth + offline refresh tokens for the reps
* **Python:** Python 3.11+
* **TypeScript:** Node 18+ **and** Python 3.11+ on the host

<Warning>
  TypeScript recipe runs on Node only. Pin Next.js route handlers to `export const runtime = "nodejs"`. See [Installation → JavaScript / TypeScript SDK](/setup/installation#javascript-typescript-sdk).
</Warning>

### Install

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

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

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

### Configure

<CodeGroup>
  ```bash Python theme={null}
  # .env
  SYNAP_API_KEY=...
  OPENAI_API_KEY=...
  SALESFORCE_CLIENT_ID=...
  SALESFORCE_CLIENT_SECRET=...
  ```

  ```bash TypeScript theme={null}
  # .env.local
  SYNAP_API_KEY=...
  OPENAI_API_KEY=...
  SALESFORCE_CLIENT_ID=...
  SALESFORCE_CLIENT_SECRET=...
  ```
</CodeGroup>

## Build it

### 1. Identity & scoping

* `customer_id = <salesforce_org_id>`: one tenant per Salesforce org
* `user_id = <salesforce_user_id>`: the sales rep
* `conversation_id = <session UUID>`
* Account-scoped memories get `account_id` in metadata so you can retrieve "everything we know about Acme Corp" later

<Note>
  `conversation_id` must be a valid UUID; generate it with `crypto.randomUUID()` (JS) or `str(uuid.uuid4())` (Python), as shown below. Any Salesforce id used for `user_id`/`customer_id` that isn't already a UUID should be mapped to a deterministic UUID (e.g. `uuid.uuid5(...)`).
</Note>

<CodeGroup>
  ```python Python theme={null}
  SESSIONS: dict[str, str] = {}

  def conv_for(session_id: str) -> str:
      return SESSIONS.setdefault(session_id, str(uuid.uuid4()))
  ```

  ```typescript TypeScript theme={null}
  const SESSIONS = new Map<string, string>();

  function convFor(sessionId: string): string {
    if (!SESSIONS.has(sessionId)) SESSIONS.set(sessionId, crypto.randomUUID());
    return SESSIONS.get(sessionId)!;
  }
  ```
</CodeGroup>

### 2. Salesforce tools

These wrap your Salesforce client. Authenticate per-rep using their stored refresh token before each call.

<CodeGroup>
  ```python Python theme={null}
  from agents import function_tool

  @function_tool
  async def get_account(account_id: str) -> dict:
      """Return account name, industry, ARR, owner, and open opp count."""
      sf = await get_sf_client_for_rep()
      return sf.Account.get(account_id)

  @function_tool
  async def get_opportunities(account_id: str, stage: str = None) -> list[dict]:
      """List open opps for an account, optionally filtered by stage."""
      sf = await get_sf_client_for_rep()
      soql = f"SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunity WHERE AccountId = '{account_id}'"
      if stage: soql += f" AND StageName = '{stage}'"
      return sf.query(soql)["records"]

  @function_tool
  async def get_contacts(account_id: str) -> list[dict]:
      sf = await get_sf_client_for_rep()
      return sf.query(
          f"SELECT Id, Name, Title, Email FROM Contact WHERE AccountId = '{account_id}'"
      )["records"]

  @function_tool
  async def query_pipeline(stage: str = None, close_before: str = None) -> list[dict]:
      """Query the rep's own pipeline. Returns opps owned by the current rep."""
      sf = await get_sf_client_for_rep()
      soql = "SELECT Id, AccountId, Name, StageName, Amount, CloseDate FROM Opportunity WHERE OwnerId = '{me}'"
      if stage: soql += f" AND StageName = '{stage}'"
      if close_before: soql += f" AND CloseDate <= {close_before}"
      return sf.query(soql.format(me=sf.user_id))["records"]

  @function_tool
  async def log_activity(opportunity_id: str, subject: str, body: str) -> dict:
      sf = await get_sf_client_for_rep()
      return sf.Task.create({"WhatId": opportunity_id, "Subject": subject, "Description": body})

  @function_tool
  async def update_opportunity(opportunity_id: str, fields: dict) -> dict:
      sf = await get_sf_client_for_rep()
      return sf.Opportunity.update(opportunity_id, fields)

  @function_tool
  async def create_task(opportunity_id: str, subject: str, due_date: str) -> dict:
      sf = await get_sf_client_for_rep()
      return sf.Task.create({
          "WhatId": opportunity_id, "Subject": subject,
          "ActivityDate": due_date, "Status": "Open",
      })
  ```

  ```typescript TypeScript theme={null}
  import { tool } from "ai";
  import { z } from "zod";

  const sfTools = {
    get_account: tool({
      description: "Return account name, industry, ARR, owner, and open opp count.",
      parameters: z.object({ accountId: z.string() }),
      execute: async ({ accountId }) => (await getSfClient()).sobject("Account").retrieve(accountId),
    }),
    get_opportunities: tool({
      description: "List open opps for an account, optionally filtered by stage.",
      parameters: z.object({ accountId: z.string(), stage: z.string().optional() }),
      execute: async ({ accountId, stage }) => {
        const sf = await getSfClient();
        let soql = `SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunity WHERE AccountId = '${accountId}'`;
        if (stage) soql += ` AND StageName = '${stage}'`;
        return (await sf.query(soql)).records;
      },
    }),
    get_contacts: tool({
      description: "List contacts on an account.",
      parameters: z.object({ accountId: z.string() }),
      execute: async ({ accountId }) => {
        const sf = await getSfClient();
        return (await sf.query(
          `SELECT Id, Name, Title, Email FROM Contact WHERE AccountId = '${accountId}'`
        )).records;
      },
    }),
    log_activity: tool({
      description: "Log an activity / call note against an opportunity.",
      parameters: z.object({ opportunityId: z.string(), subject: z.string(), body: z.string() }),
      execute: async ({ opportunityId, subject, body }) =>
        (await getSfClient()).sobject("Task").create({
          WhatId: opportunityId, Subject: subject, Description: body,
        }),
    }),
    update_opportunity: tool({
      description: "Update fields on an opportunity (stage, amount, close date, etc.).",
      parameters: z.object({ opportunityId: z.string(), fields: z.record(z.any()) }),
      execute: async ({ opportunityId, fields }) =>
        (await getSfClient()).sobject("Opportunity").update({ Id: opportunityId, ...fields }),
    }),
    create_task: tool({
      description: "Create a follow-up task tied to an opportunity.",
      parameters: z.object({
        opportunityId: z.string(), subject: z.string(), dueDate: z.string(),
      }),
      execute: async ({ opportunityId, subject, dueDate }) =>
        (await getSfClient()).sobject("Task").create({
          WhatId: opportunityId, Subject: subject, ActivityDate: dueDate, Status: "Open",
        }),
    }),
  };
  ```
</CodeGroup>

<Warning>
  The SOQL examples above use string interpolation for clarity. **In production, parameterize all SOQL inputs** to avoid injection; both `simple-salesforce` and `jsforce` support bind parameters.
</Warning>

### 3. System prompt

```text System prompt theme={null}
You are a sales assistant embedded next to Salesforce. The user is a sales rep.

- Always check the rep's pipeline and the relevant account before suggesting actions.
- Use the rep's known tone, ICP framing, and prior account narratives from memory when drafting outreach.
- Never invent fields. If a value isn't in the CRM or memory, say so and ask.
- When asked to draft, return the draft text; do not auto-send.
- When asked to update the CRM, summarize the change and confirm before calling the update tool, unless the rep prefixes the request with "just" ("just log that call").
- Keep replies tight. Reps are busy.
```

### 4. Wire memory + LLM + tools

<CodeGroup>
  ```python Python theme={null}
  import os, uuid, asyncio
  from agents import Agent, FunctionTool, Runner
  from maximem_synap import MaximemSynapSDK
  from synap_openai_agents import create_search_tool, create_store_tool

  sdk = MaximemSynapSDK()
  await sdk.initialize()

  async def handle_message(rep_id: str, org_id: str, session_id: str, text: str) -> str:
      conv_id = conv_for(session_id)

      synap_search = create_search_tool(sdk=sdk, user_id=rep_id, customer_id=org_id)
      synap_store  = create_store_tool(sdk=sdk, user_id=rep_id, customer_id=org_id)

      agent = Agent(
          name="sf_sales_assistant",
          instructions=SYSTEM,
          tools=[
              FunctionTool(synap_search, name_override="synap_search"),
              FunctionTool(synap_store,  name_override="synap_store"),
              get_account, get_opportunities, get_contacts, query_pipeline,
              log_activity, update_opportunity, create_task,
          ],
      )

      result = await Runner.run(agent, input=text)
      reply = result.final_output

      asyncio.create_task(sdk.memories.create(
          document=f"Rep: {text}\nAssistant: {reply}",
          document_type="ai-chat-conversation",
          user_id=rep_id,
          customer_id=org_id,
          metadata={"conversation_id": conv_id, "channel": "sf-sidebar"},
      ))
      return reply
  ```

  ```typescript TypeScript theme={null}
  import { generateText } from "ai";
  import { openai } from "@ai-sdk/openai";
  import { createSynap } from "@maximem/synap-vercel-adk";

  const synap = await createSynap({ apiKey: process.env.SYNAP_API_KEY! });

  export async function handleMessage(
    repId: string,
    orgId: string,
    sessionId: string,
    text: string,
  ): Promise<string> {
    const conversationId = convFor(sessionId);

    const model = synap.wrap(openai("gpt-4o"), {
      userId: repId,
      customerId: orgId,
      conversationId,
    });

    const { text: reply } = await generateText({
      model,
      system: SYSTEM,
      prompt: text,
      tools: sfTools,
    });

    return reply;
  }
  ```
</CodeGroup>

### 5. Tagging account-scoped memories

For richer recall ("what's the latest on Acme?"), tag memories with the account when the conversation is about a specific account:

<CodeGroup>
  ```python Python theme={null}
  asyncio.create_task(sdk.memories.create(
      document=note_text,
      document_type="sales-note",
      user_id=rep_id,
      customer_id=org_id,
      metadata={"account_id": account_id, "opportunity_id": opp_id},
  ))
  ```

  ```typescript TypeScript theme={null}
  synap.sdk.memories.create({
    document: noteText,
    documentType: "sales-note",
    userId: repId,
    customerId: orgId,
    metadata: { accountId, opportunityId: oppId },
  });
  ```
</CodeGroup>

You can then filter retrieval by `metadata.account_id` for account-specific summarization.

## Run & verify

```text Session 1 theme={null}
Rep:       I'm prepping for a call with Acme Corp tomorrow. What's the latest?
Assistant: Acme Corp: open opp "Acme Q2 Expansion", $120K, stage Proposal, close 2026-06-15.
           Last activity: Maya (champion, VP Eng) raised concerns about SSO timeline on 5/8.
           You drafted a response covering Q3 SSO availability but didn't send it.
           Want the draft text?

Rep:       Yes, and remind me Maya prefers Loom over Zoom for product walk-throughs.
Assistant: Noted (Maya prefers Loom). Here's the draft: ...
```

```text Session 2 (next day, fresh conversation) theme={null}
Rep:       Call with Maya done. Going to log it: we agreed on SSO by Q3, she's bringing security in next week.
Assistant: Logging on Acme Q2 Expansion: "Call with Maya, agreed SSO by Q3, security review next week."
           Want me to also create a follow-up task to prep the security review deck?

Rep:       Yes, due Friday.
Assistant: Task created on Acme Q2 Expansion, due Friday. (Also remembered: Maya prefers Loom for follow-ups.)
```

Memory carries across days. CRM stays the source of truth for structured deal state; Synap carries the *narrative*.

## Customize / extend

* **Slack interface** → wrap `handle_message` in a Slack bot. See [Patterns → Slack Bot](/patterns/slack-bot).
* **Replay historical activity** on initial setup so the assistant has years of context from day one. See [Patterns → Replay History](/patterns/replay-history).
* **Per-territory scoping** → if a single rep covers multiple territories that shouldn't share context, use `metadata.territory` to filter.
* **HubSpot / other CRMs** → replace the Salesforce tool layer; the memory pattern is identical.
* **SDR variant** → for top-of-funnel work, see [AI SDR](/cookbook/b2b-sdr).

## Troubleshooting

**Account narrative goes missing across sessions**

* Confirm `customer_id` is the org ID, not the rep ID. Reps within the same org should see shared account context if you want that.
* If you want strict per-rep silos, keep `customer_id = org_id` and rely on `user_id` for isolation; Synap scopes searches automatically.

**Drafts don't sound like the rep**

* The rep hasn't given enough signal yet. Capture explicit corrections ("don't open with 'I hope this finds you well'") with `synap_store`.
* Or seed memory at onboarding with 5 to 10 of the rep's recent sent emails as historical documents.

**Tools timing out**

* Salesforce REST has per-org rate limits. Cache `get_account` and `get_opportunities` for the duration of one chat session.

**TS route fails on Vercel**

* Pin `export const runtime = "nodejs"`. JS SDK requires Node + Python on the host.

## 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) · [Memory Scopes](/concepts/memory-scopes) · [Organizational Context](/concepts/context-end-to-end#organizational-context)
* **Patterns:** [Slack Bot](/patterns/slack-bot) · [Replay History](/patterns/replay-history) · [Multi-Tenant SaaS](/patterns/multi-tenant-saas)
* **Guides:** [Multi-User Memory Scoping](/guides/multi-user-scoping)
* **Other recipes:** [AI SDR](/cookbook/b2b-sdr) · [Tier Escalation](/cookbook/support-tier-escalation)
