The agent knew everything about the contact. Name, role, three months of interaction history, buying signals, the fact that she'd mentioned budget constraints twice in the last call. Textbook recall.

Then the agent sent her a cold discovery message asking what problems she was trying to solve.

The problem wasn't the contact's profile. It was that the agent had no idea that her company had been a customer for two years, had three open support tickets, and was currently in a renewal conversation with the account executive. The agent saw a person. It didn't see a person inside a relationship inside an organization.

This is the failure mode that single-entity memory produces at scale. And it's more common than it should be.


TL;DR

  • Most memory systems store entities in isolation. The real world is a graph — contacts belong to companies, deals belong to contacts, tickets belong to accounts.
  • Multi-entity memorization means ingesting each entity type into its own collection, linked by multiple shared identifiers stored at write time.
  • Storing secondary keys during memorization (e.g., a contact's email AND their company's website_url) is what makes cross-entity lookups possible later — without maintaining explicit foreign-key relationships.
  • Multi-entity recall has four modes: smartDigest for entity context, scoped smartRecall for specific facts, org-wide smartRecall for cross-entity discovery, and direct recall for deterministic dumps.
  • fast_mode on smartRecall cuts latency from ~15s to ~500ms — essential for real-time agents.
  • The result isn't just "more data" — it's a qualitatively different level of reasoning. Agents that understand relationships make decisions that agents with flat profiles can't.

Why entities don't exist in isolation

A contact has a title, a communication history, a set of stated pain points. That's useful. But a contact is also a member of a buying committee, an employee of a company at a particular growth stage, a stakeholder in deals at various stages, and a person whose behavior patterns only make sense in the context of their organization's recent history.

None of that context lives on the contact record. It lives on adjacent entities — and the distance between them determines the quality of every agent that acts on the contact.

The same holds in reverse. A company account has revenue, industry, stack, and health signals. But a company is also a collection of individual stakeholders with different interests, a set of open tickets that reveal integration pain, and a history of product usage that predicts renewal likelihood. The company profile alone misses all of it.

Multi-entity memory is the design pattern that collapses this gap. Instead of storing what you know about an entity, you store what you know about the network of entities — and you recall across that network at inference time.

This isn't a subtle architectural nicety. It's the difference between agents that describe records and agents that understand situations.


The memorization side: entity types, collections, and multiple keys

The first design decision is schema: how many entity types do you need, and what are the shared identifiers that let you traverse between them?

A typical B2B setup has at least four entity types that require separate collections:

| Entity Type | Primary Key | Secondary Keys | What It Captures | |---|---|---|---| | Contact | email | website_url (company domain) | Individual profile, communication history, stated preferences, behavioral signals | | Company | website_url | — | Account health, firmographics, stack, relationship history, competitive signals | | Deal | customKeyName: 'dealId' | email (primary contact) | Stage, value, timeline, stakeholders, objections, next steps | | Support Ticket | customKeyName: 'ticketId' | website_url (account), email (contact) | Issue category, resolution, sentiment, recurring patterns |

The "Secondary Keys" column is where most implementations fail. Most teams store one identifier per entity and then wonder why they can't do cross-entity queries later. The right design stores every relationship identifier at write time.

Here's what that looks like:

import { Personize } from '@personize/sdk';
 
const client = new Personize({ secretKey: process.env.PERSONIZE_SECRET_KEY! });
 
// Contacts — stored with BOTH email AND website_url as keys
// This is what makes "all contacts at acme.com" queries possible later
await client.memory.memorizeBatch({
    items: contacts.map(contact => ({
        content: `Contact: ${contact.name}, ${contact.title} at ${contact.company}.
Last interaction: ${contact.lastInteraction}.
Notes: ${contact.notes}`,
        email: contact.email,
        website_url: `https://${contact.emailDomain}`,  // secondary key — links contact to company
        collectionName: 'Contact',
        extractMemories: true,
        intelligence: 'pro',
    })),
});
 
// Companies — keyed by website_url, no synthetic email needed
await client.memory.memorizeBatch({
    items: companies.map(company => ({
        content: `Company: ${company.name}. Industry: ${company.industry}.
ARR: ${company.arr}. Health score: ${company.healthScore}.
Stack: ${company.techStack.join(', ')}.
Recent events: ${company.recentEvents}`,
        website_url: company.websiteUrl,  // primary key
        collectionName: 'Company',
        extractMemories: true,
        intelligence: 'pro',
    })),
});
 
// Deals — keyed by dealId, with email + website_url as secondary keys
await client.memory.memorizeBatch({
    items: deals.map(deal => ({
        content: `Deal: ${deal.name}. Stage: ${deal.stage}. Value: $${deal.value}.
Primary contact: ${deal.contactEmail}.
Last activity: ${deal.lastActivity}. Next step: ${deal.nextStep}.
Key objections: ${deal.objections}`,
        customKeyName: 'dealId',
        customKeyValue: deal.id,           // primary key
        email: deal.contactEmail,           // secondary key — links deal to contact
        website_url: deal.companyUrl,       // secondary key — links deal to company
        collectionName: 'Deal',
        extractMemories: true,
        intelligence: 'pro',
    })),
});
 
// Support tickets — keyed by ticketId, linked to both company and contact
await client.memory.memorizeBatch({
    items: tickets.map(ticket => ({
        content: `Ticket #${ticket.id}: ${ticket.title}. Status: ${ticket.status}.
Severity: ${ticket.severity}. Category: ${ticket.category}.
Resolution: ${ticket.resolution}. Sentiment: ${ticket.sentiment}.`,
        customKeyName: 'ticketId',
        customKeyValue: ticket.id,
        website_url: ticket.companyUrl,     // secondary key — links ticket to company
        email: ticket.contactEmail,         // secondary key — links ticket to contact
        collectionName: 'SupportTicket',
        extractMemories: true,
        intelligence: 'pro',
    })),
});

Four things to notice.

First, each entity type goes into its own collection. Mixing contact notes with company signals degrades extraction quality — the AI can't distinguish a personal preference from an account-level pattern, and retrieval becomes noise.

Second, contacts are stored with website_url as a secondary key. This is the relationship link. When you later query search({ website_url: 'https://acme.com', type: 'Contact' }), it returns all contacts at Acme — without any explicit join logic. The relationship was encoded at write time.

Third, deals and tickets are stored with customKeyName/customKeyValue as primary keys, with both email and website_url as secondary keys. This means you can look up a deal by deal ID, by contact email, or by company URL. Three lookup paths from one write.

Fourth, extractMemories: true runs AI extraction at write time. Structured properties are ready at recall time — no parsing on hot paths.


The secondary key pattern: relationships without a join table

The most underused feature in multi-entity memory design is secondary keys.

Every call to memorize() or memorizeBatch() accepts multiple CRM key identifiers: email, website_url, record_id, and any number of customKeyName/customKeyValue pairs. Every identifier you provide gets stored on that memory record. Any of them can be used to retrieve it later.

This is how cross-entity traversal works without foreign keys. Consider the practical implications:

// "Find all contacts at Acme Corp" — no join needed
// Works because contacts were memorized with website_url as a secondary key
const acmeContacts = await client.memory.search({
    type: 'Contact',
    website_url: 'https://acme.com',
    returnRecords: true,
});
 
// "Find all support tickets for this contact's company"
// Works because tickets were memorized with website_url as a secondary key
const companyTickets = await client.memory.search({
    type: 'SupportTicket',
    website_url: 'https://acme.com',
    returnRecords: true,
});
 
// "Find all open deals for this company"
const companyDeals = await client.memory.search({
    type: 'Deal',
    website_url: 'https://acme.com',
    returnRecords: true,
    groups: [{
        id: 'open',
        logic: 'AND',
        conditions: [{ field: 'stage', operator: 'NEQ', value: 'closed' }],
    }],
});

None of these require maintaining a relational schema. The relationship is embedded in every memory record at write time. If the secondary key wasn't stored, the lookup doesn't work — which is why schema design decisions at memorization time determine the power of the recall layer.

For non-standard entity types, custom keys work the same way:

// Memorize a student with multiple lookup paths
await client.memory.memorize({
    content: 'Student: Alice Chen, enrolled in Computer Science. GPA: 3.8.',
    customKeyName: 'studentNumber',
    customKeyValue: 'S-2024-1234',    // primary key
    email: 'alice@university.edu',     // secondary key
    collectionName: 'Student',
    extractMemories: true,
});
 
// Later: look up by either key
const byStudentNumber = await client.memory.recall({
    query: 'Academic record',
    type: 'Student',
    customKeyName: 'studentNumber',
    customKeyValue: 'S-2024-1234',
});
 
const byEmail = await client.memory.search({
    type: 'Student',
    email: 'alice@university.edu',
    returnRecords: true,
});

One memorize() call with three CRM keys stored at write time, fanning out to three independent lookup paths at read time — no join table required


The recall side: four modes for different questions

Recall isn't one operation. The reference architecture has four distinct modes, each designed for a different question type:

Four recall modes — smartDigest for entity context, scoped smartRecall for task-specific facts, org-wide smartRecall for cross-entity discovery, and direct recall for deterministic dumps — compared by use case and latency

Most implementations use only the first. The second and third are where multi-entity memory gets interesting.

Scoped semantic search with fast mode

For real-time agents — any context assembly that happens in a hot path — use fast_mode: true. It skips the reflection loop and returns results in ~500ms instead of ~15 seconds, with a default min_score of 0.3.

// Standard: reflection enabled, ~15s — use for deep analysis
const deep = await client.memory.smartRecall({
    query: 'what pain points and technical concerns did this contact mention?',
    email: 'sarah.chen@initech.com',
    limit: 10,
    minScore: 0.4,
    enable_reflection: true,
    generate_answer: true,  // AI synthesizes an answer from the results
});
 
// Fast: no reflection, ~500ms — use for context assembly in hot paths
const fast = await client.memory.smartRecall({
    query: 'open deals, recent activity, stated objections',
    email: 'sarah.chen@initech.com',
    fast_mode: true,
    min_score: 0.4,
});
 
// Fast with collection scope — only search Deal memories
const dealFacts = await client.memory.smartRecall({
    query: 'deal stage, value, next steps',
    email: 'sarah.chen@initech.com',
    collectionNames: ['Deal'],
    fast_mode: true,
});

generate_answer: true is worth calling out. Instead of returning ranked chunks, it synthesizes a direct answer from the recalled memories. Use it when you want a briefing paragraph, not a list of raw facts. The tradeoff is latency — it adds an LLM call on top of the vector search.

Org-wide recall for cross-entity discovery

Omit the entity key entirely and smartRecall searches across all memories in the organization. The results include a record_id field so you can identify which entity each memory belongs to.

// "Which contacts mentioned SOC2 compliance?" — no entity scoping
const soc2Contacts = await client.memory.smartRecall({
    query: 'SOC2 compliance, security audit, enterprise security requirements',
    type: 'Contact',
    limit: 20,
    minScore: 0.5,
});
 
// Each result includes record_id — use it for targeted follow-up
const recordIds = soc2Contacts.data?.results?.map(r => r.record_id) ?? [];
 
// "Which companies have had API reliability issues?"
const apiIssueAccounts = await client.memory.smartRecall({
    query: 'API reliability, webhook failures, rate limiting problems',
    type: 'Company',
    limit: 15,
    minScore: 0.4,
    fast_mode: true,
});

This is a fundamentally different capability from scoped recall. Scoped recall answers questions about one entity. Org-wide recall answers questions about your entire customer base. The second is what makes intelligence accumulate — you can find which accounts share a pattern, which contacts have expressed similar pain points, which companies are showing churn signals simultaneously.

One important caveat: org-wide results are capped by limit. If 500 contacts mention "security requirements," you get the top N most semantically similar chunks, not all 500. For exhaustive lists by structured criteria, use search() with filter conditions instead.


The full context assembly pattern

Before any agent acts on a contact, it assembles context from multiple layers and multiple entity types simultaneously:

async function assembleContext(contactEmail: string, task: string): Promise<string> {
    const domain = contactEmail.split('@')[1];
    const companyUrl = `https://${domain}`;
 
    // All fetches in parallel — never serialize independent calls
    const [governance, contactDigest, companyDigest, dealFacts, recentTickets] = await Promise.all([
        client.ai.smartGuidelines({
            message: `${task} — tone, ICP criteria, competitive positioning`,
            mode: 'fast',
        }),
        client.memory.smartDigest({
            email: contactEmail,
            type: 'Contact',
            token_budget: 1200,
        }),
        client.memory.smartDigest({
            website_url: companyUrl,
            type: 'Company',
            token_budget: 800,
        }),
        // Fast mode for deal facts — hot path
        client.memory.smartRecall({
            query: 'deal stage, value, objections, next steps',
            email: contactEmail,
            collectionNames: ['Deal'],
            fast_mode: true,
            min_score: 0.35,
        }),
        // Scoped to company tickets — uses secondary key stored at memorization time
        client.memory.smartRecall({
            query: 'open issues, integration problems, escalations',
            website_url: companyUrl,
            collectionNames: ['SupportTicket'],
            fast_mode: true,
            min_score: 0.35,
        }),
    ]);
 
    return [
        governance.data?.compiledContext || '',
        '--- CONTACT ---',
        contactDigest.data?.compiledContext || 'No contact history.',
        '--- COMPANY ---',
        companyDigest.data?.compiledContext || 'No company history.',
        '--- ACTIVE DEALS ---',
        dealFacts.data?.results?.map(r => `- ${r.text}`).join('\n') || 'No active deals.',
        '--- RECENT SUPPORT TICKETS ---',
        recentTickets.data?.results?.map(r => `- ${r.text}`).join('\n') || 'No recent tickets.',
    ].join('\n\n');
}

Notice that the ticket query uses website_url (not email) as its scoping key. This works because tickets were memorized with website_url as a secondary key. The agent gets all tickets for the contact's company — not just tickets filed by that individual contact. That distinction matters: support history is an account-level signal.

Then the agent receives that assembled context before generating:

const context = await assembleContext(contact.email, 'renewal outreach');
 
const result = await client.ai.prompt({
    context,
    instructions: [
        {
            prompt: `You have full context on the contact, their company, active deals, and recent support history.
Analyze the situation: what does this person need right now, and what risks or opportunities exist at the account level?`,
            maxSteps: 3,
        },
        {
            prompt: `Draft a personalized message. Reference specific account context — not just the individual profile.
Output MESSAGE: on a single line.`,
            maxSteps: 5,
        },
    ],
    evaluate: true,
    evaluationCriteria: 'Message must reference company or support context, not just individual contact data.',
});
The evaluation criterion is doing real work here. Without it, the agent will default to contact-only context even when company and deal data is present. Enforcing cross-entity reasoning in the evaluation step closes that loop.

Token budgets across multiple entity types

Multi-entity recall creates a token management problem single-entity memory doesn't have. Four context blocks competing for the same input budget will overflow a 4K window if you're not deliberate.

Allocate token budget proportionally to how much each entity type contributes to the specific task:

| Task | Contact | Company | Deal | Ticket | |---|---|---|---|---| | Outbound message | 1,200 | 600 | 400 | — | | Renewal prep | 600 | 1,200 | 600 | 400 | | Support escalation | 400 | 800 | — | 1,200 | | QBR brief | 800 | 1,000 | 600 | 400 |

smartDigest() accepts token_budget and truncates within that ceiling. smartRecall() with fast_mode returns up to 100 results by default — scope that with limit when you don't need that many.

Monitor tokenEstimate in smartDigest responses to detect truncation:

const digest = await client.memory.smartDigest({
    email: contactEmail,
    type: 'Contact',
    token_budget: 2000,
});
 
if (digest.data && digest.data.tokenEstimate >= digest.data.tokenBudget * 0.95) {
    // Context was truncated — increase budget or add a second targeted smartRecall
    const extra = await client.memory.smartRecall({
        query: 'most recent interactions and stated priorities',
        email: contactEmail,
        fast_mode: true,
        limit: 3,
    });
}

What this unlocks

The qualitative shift is hard to convey without seeing it. An agent with contact-only memory sees a person and asks discovery questions. An agent with cross-entity memory sees that this person's company has two unresolved tickets flagged urgent, that they're 11 days from their renewal date, and that the AE noted budget sensitivity in the last call — and responds accordingly.

That's not a marginal improvement. It's a different category of behavior.

The multi-entity pattern enables three capabilities that flat memory doesn't:

Relationship-aware personalization. Messages that reference company context, not just individual history. A note that says "given what your team went through with the Q3 integration issues" can only exist if company ticket history was memorized with website_url as a secondary key and recalled at agent runtime.

Cross-signal reasoning. An agent that sees both a contact's enthusiastic product feedback and their company's declining usage metrics can reason about the disconnect — and surface it as a risk rather than treating both signals in isolation.

Entity escalation. When contact-level recall is sparse (new lead, no prior history), the agent falls back to company-level context. A company with twelve prior touchpoints from different stakeholders is not a cold account, even if the individual contact is new. Single-entity memory would treat it as one. Multi-entity memory doesn't.


The anti-patterns

Storing only the primary key. The most common mistake. A contact memorized with only email can't be found via company URL queries. A deal memorized with only customKeyValue: dealId can't be surfaced when you query by contact email. Store every relationship identifier at write time, or accept that cross-entity lookups won't work.

Dumping everything into one collection. Contact notes, company signals, deal updates, support tickets — all in the same flat memory store. The extraction quality degrades because the AI has to infer entity type from content, and retrieval becomes noise because a query for "renewal concerns" pulls contact budget notes and company churn signals with equal weight.

Treating entity relationships as implicit. Assuming the email domain is enough to link a contact to their company without storing website_url. It usually works. Until it doesn't — when a contact uses a personal Gmail, when a company has multiple domains, when a deal spans multiple contacts. Build the relationship into every memory record explicitly.

Sequential context assembly. Fetching contact digest, then company digest, then deal context in a chain. This triples your latency for no reason. The Promise.all() pattern above isn't stylistic — it's load-bearing. Parallel recall on five entity lookups takes roughly the same wall time as sequential recall on one.

Using full recall mode in hot paths. smartRecall with reflection enabled takes ~10-20 seconds. For context assembly in agent pipelines — anything where a user or downstream system is waiting — use fast_mode: true. Reserve full reflection mode for batch analysis and deep research queries.


The architectural principle

Single-entity memory is a lookup. Multi-entity memory is a knowledge graph with a recall interface.

The difference matters because the real world is a graph. Contacts are embedded in organizations. Deals are embedded in relationship histories. Support issues are embedded in accounts with specific stack configurations and usage patterns. Any agent reasoning about one node in isolation is reasoning about an abstraction that doesn't reflect how things actually work.

The multi-entity pattern requires four decisions: what entity types matter for your domain, what secondary keys encode the relationships between them, which recall mode serves each question type, and how to allocate token budget at inference time based on the task.

Get those four right and the agents you build will reason about situations instead of records. That's the gap most teams never close — not because the technology is hard, but because they never designed the memory layer to hold relationships.


How to set this up

npx skills add personizeai/personize-skills

The Personize Skills include the Solution Architect skill, which walks through entity schema design, relationship modeling, and recall strategy for your specific domain. The source is open on GitHub.