I found this bug because my support agent sounded way too confident for someone who was completely wrong.
A customer messaged in Slack.
Then emailed support the next day.
Then showed up in HubSpot after a sales handoff.
My agent treated all three like different people.
Not slightly different. Fully unrelated-human-being different.
The Slack thread said, "As I mentioned yesterday..." and the agent replied like it had never seen them before.
At first I blamed the model.
Maybe GPT-5 lost the thread.
Maybe Claude was getting distracted.
Maybe my retrieval was bad.
Maybe my summaries were weak.
But that wasn’t the problem.
The agent had seen:
- a Slack user ID
- an email address
- a HubSpot contact
Three records. Same person.
That’s when it clicked: a lot of so-called agent memory bugs are really identity bugs.
The real problem: the agent remembered events, but not the person
A lot of teams try to solve continuity by feeding the model more context:
- longer transcripts
- bigger summaries
- more retrieval chunks
- vector search over every past conversation
- one giant never-ending thread
I’ve built that version.
It feels smart right up until it starts confidently using the wrong context.
For a single ticket, short-term thread memory is fine.
For cross-channel support, it’s the wrong abstraction.
A support agent does not need one immortal chat transcript.
It needs a durable customer object and a clean event trail.
That sounds less exciting than “memory architecture.”
It also works better.
Why Slack makes this easy to mess up
Slack identity feels more universal than it actually is.
You see:
- a display name
- a mention
- a DM participant
- maybe an email in the profile
So your brain says: cool, that’s the customer.
But Slack user IDs are workspace-scoped. They are not a durable cross-system customer key.
You can bridge from email to Slack with users.lookupByEmail, but that has caveats:
- requires
users:read.email - rate limited at Tier 3,
50+requests per minute - deactivated users can return
users_not_found - custom bot users can’t use it
Example:
GET https://slack.com/api/users.lookupByEmail?email=spengler@ghostbusters.example.com
Authorization: Bearer xoxb-...
Useful? Yes.
Source of truth? No.
If your workflow treats Slack identity as globally stable, you’re building on sand.
HubSpot, Zendesk, and Slack all disagree about identity
That’s the actual problem.
Each system has a different opinion about who a person is.
HubSpot
HubSpot strongly pushes email as the primary unique identifier for contacts.
That’s reasonable for CRM hygiene.
POST /crm/v3/objects/contacts
{
"properties": {
"email": "example@hubspot.com",
"firstname": "Jane",
"lastname": "Doe"
}
}
But email is not durable enough to be your only identity key.
People:
- change jobs
- use personal email first and work email later
- write from shared inboxes like
ops@company.com - contact support from multiple addresses
Email is a strong clue.
It is not bedrock.
Zendesk
Zendesk has the more realistic model for support.
A Zendesk user can have multiple identities:
- phone
- messaging identity
- SDK identity
- more
It also supports external_id, which is exactly what you want for cross-system mapping.
Even better, external_id is case-insensitive.
That’s much closer to reality:
Channels are not people.
People show up through channels.
Quick comparison
| Identity source | What it gives you |
|---|---|
| Slack user identity | Workspace-scoped user ID, optional profile email, lookup by email via users.lookupByEmail; useful for stitching, bad as a global customer key |
| HubSpot contact identity | Email as primary unique identifier plus HubSpot record ID; solid CRM anchor, but email can change |
| Zendesk user identity | Multiple identities plus external_id; best fit for cross-channel support mapping |
If you want continuity, the winner is not “the app with the most chat history.”
It’s the model that supports identity linking.
What the agent should remember instead
Not a giant transcript.
Not ten summaries duct-taped together.
Not every message the customer ever sent.
The durable thing should be a customer entity with:
- an internal canonical ID
- linked identities for Slack, email, HubSpot, Zendesk, phone, web chat, whatever else you use
- an append-only event log
- a small set of current state fields
For example:
{
"customer_id": "cust_12345",
"identities": {
"slack_user_id": "U123ABC",
"emails": ["jane@company.com", "jane.doe@gmail.com"],
"hubspot_contact_id": "1049382",
"zendesk_user_id": "88219",
"zendesk_external_id": "cust_12345"
},
"state": {
"plan": "enterprise",
"owner": "sales_17",
"open_tickets": 2,
"risk_flag": true,
"last_issue": "billing export failure"
}
}
That’s the thing your agent should query.
The pattern I’d build today
Here’s the practical version.
1. Create a canonical customer record
Give every real customer an internal ID.
Something like:
cust_12345
That ID is the anchor.
Not Slack.
Not email.
Not HubSpot.
Not Zendesk.
2. Store channel identities as aliases
Example aliases:
Slack user ID: U123ABC
Email: jane@company.com
HubSpot contact ID: 1049382
Zendesk user ID: 88219
Zendesk external_id: cust_12345
Treat those as handles pointing at the same customer.
Not as competing truths.
3. Append events instead of rewriting history
Every interaction becomes an event:
- Slack DM received
- Email ticket opened in Zendesk
- HubSpot lifecycle stage changed
- Refund issued
- Escalation created
- Account upgraded
Example schema:
{
"event_id": "evt_9001",
"customer_id": "cust_12345",
"source": "slack",
"type": "message_received",
"timestamp": "2026-10-17T14:22:31Z",
"payload": {
"channel": "C12345",
"text": "As I mentioned yesterday, the export still fails"
}
}
Then retrieve only the relevant slices:
- recent unresolved issues
- prior promises made by support
- current plan and account state
- recent escalations
That’s better than dragging a 40-page transcript into every prompt.
4. Merge later when certainty arrives
This matters more than people think.
If someone first appears in Slack and you only know them as “Jane from Acme,” don’t pretend you know exactly who they are.
Create a provisional identity.
Merge it later when you get stronger evidence.
Example merge flow:
# provisional identity created from Slack
customer create --provisional \
--source slack \
--slack-user-id U123ABC
# later, resolve with email + CRM match
customer merge \
--from provisional_9981 \
--into cust_12345
That’s a lot safer than guessing wrong and poisoning your memory layer.
Minimal implementation sketch
If I were wiring this into an agent service, I’d start with something like this:
type Customer = {
customerId: string;
identities: {
slackUserIds: string[];
emails: string[];
hubspotContactIds: string[];
zendeskUserIds: string[];
externalIds: string[];
};
state: {
plan?: string;
owner?: string;
openTickets?: number;
lastIssue?: string;
riskFlag?: boolean;
};
};
type Event = {
eventId: string;
customerId: string;
source: "slack" | "zendesk" | "hubspot" | "email";
type: string;
timestamp: string;
payload: Record<string, unknown>;
};
And the resolver logic would look more like this than “just load the last 200 messages”:
async function resolveCustomer(input: {
slackUserId?: string;
email?: string;
hubspotContactId?: string;
zendeskUserId?: string;
}) {
if (input.hubspotContactId) {
const byHubSpot = await findByIdentity("hubspotContactId", input.hubspotContactId);
if (byHubSpot) return byHubSpot;
}
if (input.zendeskUserId) {
const byZendesk = await findByIdentity("zendeskUserId", input.zendeskUserId);
if (byZendesk) return byZendesk;
}
if (input.email) {
const byEmail = await findByIdentity("email", input.email.toLowerCase());
if (byEmail) return byEmail;
}
if (input.slackUserId) {
const bySlack = await findByIdentity("slackUserId", input.slackUserId);
if (bySlack) return bySlack;
}
return createProvisionalCustomer(input);
}
That’s not fancy.
That’s the point.
Why “more memory” often makes agents worse
This was the part that changed how I build support agents.
When continuity broke, my first instinct was to feed the model more:
- more thread history
- more summaries
- more retrieval chunks
- more old tickets
That made the agent sound informed while becoming less reliable.
It started:
- pulling stale promises from old tickets
- mixing unrelated incidents together
- answering current Slack questions with context from an old Zendesk exchange
- overfitting to clutter
This is why I’m skeptical when people say they solved cross-platform memory by just increasing context.
Long context is not the same thing as good memory.
A lot of the time it’s just expensive confusion.
And if you’re running agents at any meaningful volume, that confusion is not cheap.
More tokens means more latency, more cost, and more opportunities for the model to grab the wrong detail.
That’s one reason I like the Standard Compute model for agent workloads: if you’re iterating on multi-step automations, support flows, or identity-resolution-heavy pipelines, flat-rate API access is a lot easier to work with than babysitting token spend every time you test retrieval changes.
If your stack already speaks the OpenAI API, Standard Compute is a drop-in replacement. Same shape, way less pricing anxiety for long-running agents.
That matters when your first few versions are inevitably messy.
A real workflow example
Say this happens over 48 hours:
- User DMs your Slack support bot
- Sales creates a HubSpot contact after handoff
- User emails support from the same domain
- Zendesk opens a ticket
- Agent needs to answer with continuity
Bad implementation:
- store separate thread history per system
- hope email matching works later
- dump all prior messages into prompt
Better implementation:
customer_id: cust_12345
aliases:
- slack:U123ABC
- hubspot:1049382
- email:jane@company.com
- zendesk:88219
events:
- slack_message_received
- hubspot_contact_created
- zendesk_ticket_opened
- support_reply_sent
- escalation_created
Prompt assembly becomes much cleaner:
const customer = await resolveCustomer({
slackUserId: "U123ABC",
email: "jane@company.com"
});
const context = await getRelevantEvents(customer.customerId, {
limit: 10,
types: [
"zendesk_ticket_opened",
"support_reply_sent",
"escalation_created",
"account_updated"
]
});
const prompt = buildPrompt({
customer,
context,
currentMessage: "As I mentioned yesterday, the export still fails"
});
Now the model gets:
- who this is
- what matters right now
- which prior events are relevant
Not a landfill of chat logs.
Isn’t email enough?
Sometimes, yes.
If you have:
- one channel
- short-lived tasks
- simple support flows
- no repeated cross-system interactions
Then email might be enough.
You do not need to build a tiny CDP just to answer five support emails a week.
But once you span Slack, email, CRM, ticketing, and repeated interactions over time, email stops being enough.
That’s when identity resolution stops being “nice to have” and starts being core infrastructure.
Practical takeaway
If your support or ops agent keeps “forgetting” people across Slack, HubSpot, and Zendesk, stop tuning prompts for a minute.
Ask the uglier question:
What is the durable identity for this person, and where is the event log attached to it?
If the answer is:
- a Slack mention
- a display name
- whatever email showed up most recently
- the current chat thread
then your agent is going to keep acting like it has amnesia.
Build around:
- a canonical internal customer ID
- linked channel identities
- append-only events
- narrow retrieval over events and state
- explicit merge logic for provisional identities
That’s when continuity starts feeling real.
Not because GPT-5 or Claude suddenly became perfect historians.
Because your agent finally knows who it’s talking to.
If you’re building high-volume support agents, automation flows, or multi-step LLM systems, this is also where pricing starts to matter. Identity resolution + retrieval + retries can generate a lot of calls fast. Standard Compute is worth a look if you want OpenAI-compatible API access without per-token cost anxiety: https://standardcompute.com
Top comments (0)