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} = awaitgenerateText({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} = awaitgenerateText({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} = awaitgenerateText({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:
constjob = awaitapi.memories.create({source:"text",content:memoryContent,infer:false,},{baseId},);// Wait for the memory ID to resolve from the async jobconstmemoryId = awaitmemoryIdFromCreateJob(api,job,baseId);// Optionally rename for easier browsingawaitapi.memories.update({memoryId,name:"sales-memory: Acme Corp - Objections"},{baseId},);
constjob = awaitapi.memories.create({source:"text",content:memoryContent,infer:false,},{baseId},);// Wait for the memory ID to resolve from the async jobconstmemoryId = awaitmemoryIdFromCreateJob(api,job,baseId);// Optionally rename for easier browsingawaitapi.memories.update({memoryId,name:"sales-memory: Acme Corp - Objections"},{baseId},);
constjob = awaitapi.memories.create({source:"text",content:memoryContent,infer:false,},{baseId},);// Wait for the memory ID to resolve from the async jobconstmemoryId = awaitmemoryIdFromCreateJob(api,job,baseId);// Optionally rename for easier browsingawaitapi.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:
constsearchRes = awaitapi.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
constsearchRes = awaitapi.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
constsearchRes = awaitapi.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} = awaitgenerateText({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} = awaitgenerateText({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} = awaitgenerateText({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:
exportasyncfunctionmemoryIdFromCreateJob(api,job,baseId){letcurrent = job;for(letattempt = 0;attempt < 60;attempt++){if(current.memories?.created?.length){returncurrent.memories.created[0];}if(current.status !== "pending")break;awaitnewPromise((r)=>setTimeout(r,300));current = awaitapi.memories.getJob({jobId:current.id},{baseId});}thrownewError("Memory creation did not return a memory id");}
exportasyncfunctionmemoryIdFromCreateJob(api,job,baseId){letcurrent = job;for(letattempt = 0;attempt < 60;attempt++){if(current.memories?.created?.length){returncurrent.memories.created[0];}if(current.status !== "pending")break;awaitnewPromise((r)=>setTimeout(r,300));current = awaitapi.memories.getJob({jobId:current.id},{baseId});}thrownewError("Memory creation did not return a memory id");}
exportasyncfunctionmemoryIdFromCreateJob(api,job,baseId){letcurrent = job;for(letattempt = 0;attempt < 60;attempt++){if(current.memories?.created?.length){returncurrent.memories.created[0];}if(current.status !== "pending")break;awaitnewPromise((r)=>setTimeout(r,300));current = awaitapi.memories.getJob({jobId:current.id},{baseId});}thrownewError("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.