Examples

Build a Sales Memory Copilot with Exabase

Paste call notes in, get meeting prep out. This tutorial walks through how the Sales Memory Copilot example uses Exabase's Memory API to capture sales context and retrieve it for future conversations.


What it does

The app has two flows:

Capture: A salesperson pastes raw call notes, research, or CRM context. An LLM breaks the input into focused memory blocks (business context, stakeholders, objections, risks, next steps) and saves each one separately to Exabase.

Prepare: Before the next call, the user enters a client name and a question like "What objections did they raise?" The app searches Exabase memories semantically, feeds the results to an LLM, and generates a concise prep brief.

The key idea: instead of one giant blob of notes per client, the LLM splits input into small, retrievable slices. Later searches return only the relevant pieces.


How Exabase fits in

How the LLM structures raw notes

Before anything hits Exabase, the app sends the raw notes to an LLM with a prompt that asks for structured [MEMORY]...[/MEMORY] blocks:

const { text: rawMemories } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You create retrieval-friendly sales memories from noisy call and research context.",
  prompt: `Client: ${clientName}

Input context (may include call transcript, research notes, CRM updates):
${notes}

Create multiple focused memory entries when useful (business context,
stakeholders, objections, risks, commitments, next steps).

Return one or more blocks using EXACT delimiters:
[MEMORY]
Title: <short title>
Content:
Summary: <1-2 lines>
Signals:
- <bullet>
Risks:
- <bullet or n/a>
Next step:
- <bullet or n/a>
[/MEMORY]`,
});
const { text: rawMemories } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You create retrieval-friendly sales memories from noisy call and research context.",
  prompt: `Client: ${clientName}

Input context (may include call transcript, research notes, CRM updates):
${notes}

Create multiple focused memory entries when useful (business context,
stakeholders, objections, risks, commitments, next steps).

Return one or more blocks using EXACT delimiters:
[MEMORY]
Title: <short title>
Content:
Summary: <1-2 lines>
Signals:
- <bullet>
Risks:
- <bullet or n/a>
Next step:
- <bullet or n/a>
[/MEMORY]`,
});
const { text: rawMemories } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You create retrieval-friendly sales memories from noisy call and research context.",
  prompt: `Client: ${clientName}

Input context (may include call transcript, research notes, CRM updates):
${notes}

Create multiple focused memory entries when useful (business context,
stakeholders, objections, risks, commitments, next steps).

Return one or more blocks using EXACT delimiters:
[MEMORY]
Title: <short title>
Content:
Summary: <1-2 lines>
Signals:
- <bullet>
Risks:
- <bullet or n/a>
Next step:
- <bullet or n/a>
[/MEMORY]`,
});

The app then parses out each [MEMORY] block and saves them individually. If the LLM doesn't produce the expected format, it falls back to storing the raw text as a single memory — so the flow never loses data.

Capturing notes as structured memories

The LLM breaks raw input into [MEMORY]...[/MEMORY] blocks. Each block becomes a separate Exabase memory:

const job = await api.memories.create(
  {
    source: "text",
    content: memoryContent,
    infer: false,
  },
  { baseId },
);

// Wait for the memory ID to resolve from the async job
const memoryId = await memoryIdFromCreateJob(api, job, baseId);

// Optionally rename for easier browsing
await api.memories.update(
  { memoryId, name: "sales-memory: Acme Corp - Objections" },
  { baseId },
);
const job = await api.memories.create(
  {
    source: "text",
    content: memoryContent,
    infer: false,
  },
  { baseId },
);

// Wait for the memory ID to resolve from the async job
const memoryId = await memoryIdFromCreateJob(api, job, baseId);

// Optionally rename for easier browsing
await api.memories.update(
  { memoryId, name: "sales-memory: Acme Corp - Objections" },
  { baseId },
);
const job = await api.memories.create(
  {
    source: "text",
    content: memoryContent,
    infer: false,
  },
  { baseId },
);

// Wait for the memory ID to resolve from the async job
const memoryId = await memoryIdFromCreateJob(api, job, baseId);

// Optionally rename for easier browsing
await api.memories.update(
  { memoryId, name: "sales-memory: Acme Corp - Objections" },
  { baseId },
);

A single paste of call notes might produce 3–5 memories: one for context, one for objections, one for next steps, etc. Each is independently searchable.

Searching memories for meeting prep

When preparing, the app builds a search query from the client name and the user's question, then uses semantic search:

const searchRes = await api.memories.search(
  {
    query: "Acme Corp prep notes objections next step blocker",
    limit: 10,
    order: { property: "createdAt", direction: "DESC" },
  },
  { baseId },
);

// searchRes.hits contains ranked results with .content, .score, .createdAt
const searchRes = await api.memories.search(
  {
    query: "Acme Corp prep notes objections next step blocker",
    limit: 10,
    order: { property: "createdAt", direction: "DESC" },
  },
  { baseId },
);

// searchRes.hits contains ranked results with .content, .score, .createdAt
const searchRes = await api.memories.search(
  {
    query: "Acme Corp prep notes objections next step blocker",
    limit: 10,
    order: { property: "createdAt", direction: "DESC" },
  },
  { baseId },
);

// searchRes.hits contains ranked results with .content, .score, .createdAt

The hits are passed directly into the LLM prompt as context, and the model generates a structured prep brief:

const { text } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You are a sales meeting prep copilot. Use only provided memories. Keep output concise and actionable.",
  prompt: `Client: ${clientName}
User ask: ${ask || "Prepare me for the next customer conversation."}

Memories:
${memoryText}

Return a concise prep brief with sections:
- Key context
- Risks / blockers
- Suggested questions
- Next-step recommendation`,
});
const { text } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You are a sales meeting prep copilot. Use only provided memories. Keep output concise and actionable.",
  prompt: `Client: ${clientName}
User ask: ${ask || "Prepare me for the next customer conversation."}

Memories:
${memoryText}

Return a concise prep brief with sections:
- Key context
- Risks / blockers
- Suggested questions
- Next-step recommendation`,
});
const { text } = await generateText({
  model: openai(resolveOpenAiModel()),
  temperature: 0.2,
  system:
    "You are a sales meeting prep copilot. Use only provided memories. Keep output concise and actionable.",
  prompt: `Client: ${clientName}
User ask: ${ask || "Prepare me for the next customer conversation."}

Memories:
${memoryText}

Return a concise prep brief with sections:
- Key context
- Risks / blockers
- Suggested questions
- Next-step recommendation`,
});

The model can only work with what memory search returned — it won't hallucinate details from outside the stored context. This is the "grounded generation" pattern: retrieve first, generate from retrieved facts only.

Memory creation is async

Exabase memory creation returns a job, not a finished memory. The example polls for completion:

export async function memoryIdFromCreateJob(api, job, baseId) {
  let current = job;
  for (let attempt = 0; attempt < 60; attempt++) {
    if (current.memories?.created?.length) {
      return current.memories.created[0];
    }
    if (current.status !== "pending") break;
    await new Promise((r) => setTimeout(r, 300));
    current = await api.memories.getJob({ jobId: current.id }, { baseId });
  }
  throw new Error("Memory creation did not return a memory id");
}
export async function memoryIdFromCreateJob(api, job, baseId) {
  let current = job;
  for (let attempt = 0; attempt < 60; attempt++) {
    if (current.memories?.created?.length) {
      return current.memories.created[0];
    }
    if (current.status !== "pending") break;
    await new Promise((r) => setTimeout(r, 300));
    current = await api.memories.getJob({ jobId: current.id }, { baseId });
  }
  throw new Error("Memory creation did not return a memory id");
}
export async function memoryIdFromCreateJob(api, job, baseId) {
  let current = job;
  for (let attempt = 0; attempt < 60; attempt++) {
    if (current.memories?.created?.length) {
      return current.memories.created[0];
    }
    if (current.status !== "pending") break;
    await new Promise((r) => setTimeout(r, 300));
    current = await api.memories.getJob({ jobId: current.id }, { baseId });
  }
  throw new Error("Memory creation did not return a memory id");
}

This matters when you need the memory ID immediately (e.g., to rename it). If you don't need the ID, you can fire-and-forget.



Key Exabase APIs used in this example:

API

Purpose

bases.create

Create an isolated workspace per session

memories.create

Store each structured note as a separate memory

memories.getJob

Poll for the memory ID after async creation

memories.update

Rename a memory for easier browsing

memories.search

Semantic search across all saved notes



Run it yourself

git clone https://github.com/futurebrowser/exabase-examples.git
cd

git clone https://github.com/futurebrowser/exabase-examples.git
cd

git clone https://github.com/futurebrowser/exabase-examples.git
cd

Add EXABASE_API_KEY and OPENAI_API_KEY to .env.local, open http://localhost:3000, and click New base.



FAQ


Why split notes into multiple memories?

Granularity improves retrieval. If you store one big note, a search for "objections" returns the entire document. Split into focused memories, you get just the objections block — less noise for the LLM, better prep output.


What does order: { property: "createdAt", direction: "DESC" } do?

It biases search results toward recent memories. For sales, the latest call notes are usually the most relevant context.


Can I use this pattern beyond sales?

Absolutely. The capture → search → generate loop works for any domain where you accumulate unstructured notes and want to retrieve them later: recruiting, customer support, project management, legal research.


What if the LLM doesn't produce the [MEMORY]...[/MEMORY] format?

The parser falls back gracefully. If no [MEMORY] blocks are found, the entire raw text is stored as a single memory. You never lose data — the worst case is one big memory instead of several focused ones.


Is there a limit on how much text I can paste?

The app validates input at 12,000 characters max (roughly 2,000–3,000 words). That's enough for a typical call transcript or a page of CRM notes. If you need more, you could increase the max in the Zod schema or split the input across multiple captures.


Why rename memories after creation?

The example sets a title like "sales-memory: Acme Corp - Objections" via memories.update. This is purely for human readability — it makes the memory list easier to scan in the UI. Search doesn't rely on the title; it uses the full content for semantic matching.

Ship your first app in minutes.

Ship your first app in minutes.

Ship your first app in minutes.