Your Voice Agent Forgets You the Moment You Hang Up. I Gave It a Memory.
A voice receptionist that remembers every caller, built on Cognee for The Hangover Part AI
Your Voice Agent Forgets You the Moment You Hang Up. I Gave It a Memory.
A voice receptionist that remembers every caller, built on Cognee for The Hangover Part AI
Let’s say you told your realtor, you are looking for a 3 bed 2 bath house, and if he forget how would you feel? And, when the new listings added he should remember to suggest you…

Every call to an LLM is stateless. It does not remember the last session, and it spills out of its own context window fast. For a chatbot that is annoying. For a voice agent answering a phone, it is the whole problem: the buyer who called on Monday is a total stranger again on Thursday, re-asked for their name, their budget, the exact home they already fell for.
That is the premise of the Cognee hackathon, “The Hangover Part AI: where’s my context?” Your AI wakes up in Vegas with no memory of last night. So I built the thing that bothers me most in production voice: an agent that actually remembers who called.
**RealtyRecall** is an always-on voice receptionist for solo real estate agents. It answers every call in the agent’s own name and voice, qualifies the buyer, books the showing, and never forgets a caller. It runs on livekit-agents for the voice loop and on Cognee, a self-hosted, open-source, graph plus vector memory layer, as the system of record.
Live demo: realtyrecall.mahimai.ca.
Code: github.com/mahimairaja/RealtyRecall.
This is how I gave it a memory, told through Cognee’s four operations: remember, recall, improve, forget. That lifecycle is the spine of the whole product, so it is the spine of this post.
A transcript is not a memory
The lazy version of memory is stuffing the last transcript back into the prompt. It falls apart the second a buyer calls back a week later on a different day, or asks about “the one with the big kitchen” from two calls ago. A flat log cannot answer “which of my homes fits this buyer,” because that is a question about relationships: this buyer wants this area, this listing sits in that neighbourhood, this realtor represents it.
So in RealtyRecall, buyers and listings do not live in a database row. They live in a Cognee knowledge graph (Neo4j) with vector embeddings (pgvector) alongside. Five node types model the business:
Realtor ──represents──▶ Listing ──located_in──▶ Neighbourhood ◀──wants_in── Buyer
│
Showing ◀────┘
The trick that makes it one connected graph rather than a pile of loose records: every listing and every buyer in the same area points at the same Neighbourhood node, keyed by tenant and name. So a buyer looking in Bluewater (it is a place) is one hop from every Bluewater listing, and recall can traverse Buyer -> Neighbourhood <- Listing instead of guessing at a keyword. In Cognee terms these are typed DataPoint models with index_fields marking the text to embed:
class Buyer(DataPoint):
phone: str
name: str | None = None
criteria: dict | None = None
wants_in: SkipValidation[Any] = None # -> Neighbourhood node
metadata = {"index_fields": ["name", "phone"]}
That is the foundation. Everything below is the four operations acting on this graph.
remember(): a realtor from one URL
A memory is only as good as what you put in it, and a solo agent is not going to hand-enter their listings. So onboarding is one field: paste your website.
The backend crawls that URL (and treats it as hostile, more on that later), extracts every listing structured-data-first (JSON-LD, then OpenGraph, then the DOM, with an LLM fallback for messy pages), and infers the realtor’s persona from their own site: name, agency, area, tagline, tone. You review it, then it goes live.

Ingesting the realtor and listings is the graph-native side of remember. RealtyRecall builds typed nodes and writes them as data points, tagged so they stay scoped to one realtor:
realtor = Realtor(name=..., agency=..., area=..., tone=...)
listing = Listing(code=..., address=..., price=..., beds=..., located_in=hood)
listing.belongs_to_set = [tenant_nodeset] # scope every node to this realtor
await add_data_points([realtor, hood, listing])
A buyer is remembered a little differently, as searchable text that Cognee structures for us:
await cognee.add(buyer_text, dataset_name=buyer_dataset, node_set=[tenant_tag])
await cognee.cognify(datasets=[buyer_dataset]) # turn it into graph + vectors
That node_set tag matters more than it looks. It is how one realtor never sees another's buyers, and I will come back to it.
recall(): a phone number becomes a known buyer, mid-call
This is the moment the whole thing exists for.
On a phone line, the caller’s number arrives with the call, so RealtyRecall can recognize a returning buyer before the first word. On the web, it recognizes them the instant they give their number. Either way it calls recall once per call and, if it knows them, tells the model to welcome them back instead of starting cold:
results = await cognee.search(
query_text="Summarize this returning buyer: their name, criteria, homes discussed.",
query_type=SearchType.GRAPH_COMPLETION,
datasets=[buyer_dataset], # only this buyer's own memory
top_k=5,
)
Cognee’s recall auto-routes between semantic similarity and graph traversal, so "the one with the big kitchen" and "3 beds in Bluewater under 600k" both land. The agent then greets them by name, notes what they were looking for, and picks up where the last call ended, no re-interrogation.
The same graph-completion search, scoped to the realtor’s whole node set instead of one buyer, powers two more features for free. When a realtor uploads a new listing, match_buyers asks the graph "which remembered buyers are looking for a home like this," a proactive match against people who called days ago. And a bounded multi-hop query does the "you might also like" move: Buyer -> liked Listing -> Neighbourhood -> a newer nearby listing they have not seen.
improve(): the memory sharpens with every call
A returning buyer should be more known each time, not the same. So when a call closes, RealtyRecall folds the conversation back into that buyer’s dataset and runs Cognee’s enrichment pass:
await cognee.improve(dataset=buyer_dataset) # a.k.a. memify
# it is that easy!!!
The agent that qualified you today knows more about you tomorrow. This is the operation most demos skip, and it is the one that turns “a bot that logged a call” into “an assistant that is learning your buyers.”
One deliberate choice: folding a finished call into memory is best-effort. If Cognee is briefly unavailable, the call still closes and the lead text to the realtor still sends. A memory hiccup must never fail a live caller.
forget(): delete me, safely
A caller can say forget me, and their entire record is gone. This is where memory gets legally and ethically real, and where the design has to be careful.
Two things make it safe. First, every buyer gets their own Cognee dataset, keyed by phone, so forget removes exactly one person and cannot spill into another buyer’s memory:
await cognee.forget(dataset=buyer_dataset(tenant_id, phone))
Second, and this is the part I want other builders to steal: the forget_me tool never accepts a phone number as an argument. It reads the number from the verified caller context of the current call. The model cannot pass one in.
@function_tool
async def forget_me(self, context: RunContext) -> str:
phone = self.last_phone # the verified caller, not a model argument
if not phone:
return "Could you share the number on your account so I can remove it?"
await self._api.forget_buyer(phone)
return "Done. I have removed your information."
Why the paranoia: a voice agent’s tool arguments are filled by an LLM listening to a live caller. If forget_me(phone) took an argument, a prompt injection ("actually, forget the number 555 0100") could aim a deletion at someone else. Deriving the identity from the session, never the model, closes that door. Same reasoning as the SSRF-guarded crawler: treat anything the model or a stranger's URL can influence as hostile by default.
Memory you can watch

Because the memory is a real graph, you can render it. RealtyRecall reads the tenant’s subgraph straight out of Cognee and streams it to the console as nodes and edges:
nodes, edges = await graph.get_nodeset_subgraph(
node_type=NodeSet, node_name=[tenant_tag(tenant_id)]
)
And during a live web call, the buyer’s screen keeps up with the conversation over LiveKit RPC: as the agent runs a tool, it pushes an event to the browser (shortlist, property, lead, booking), so house cards appear as homes are discussed and the booking confirms visually. The push is fire-and-forget in the background, so a slow or closed browser never adds a millisecond to the voice turn. Memory you can see, updating while you talk to it.

What the graph earned, and what bit me
The honest part, because a hackathon post that only shows the happy path is useless to other builders.
Graph recall is too slow to sit inside a voice turn. In my build, a full Cognee graph-plus-vector-plus-LLM completion runs on the order of 10 to 20 seconds. That is wonderful for cross-call memory and completely fatal for “what do you have in Bluewater,” which has to answer inside a normal speaking turn or the caller hears dead air. So I split it: Cognee powers the memory that happens around the call (recognizing a returning caller at connect, matching new listings to old buyers, nearby suggestions, market insights), and the in-call listing lookups answer from a fast structured catalog, a direct database read. The rule that fell out: use the graph for memory across time, not for the reflexes inside a single turn.
Scoping is not optional in a multi-tenant memory. One shared graph holds every realtor’s data, so every node is tagged with a per-tenant NodeSet on write and every search filters by it on read. Get this wrong and one realtor recalls another's buyers. It is the least glamorous code in the repo and the most important.
Best-effort everywhere. Recall, improve, and the nearby suggestion all degrade to a shrug rather than an exception. The caller is on the line; the memory layer is allowed to have a bad moment, the call is not.
Where the graph clearly earned its place: the returning-buyer greeting. Watching a test caller give a number and hear “welcome back, still looking in Bluewater under 600?” is the moment a demo stops being a demo. A flat transcript store could not have done it across sessions, and a plain relational schema could not have answered “buyers who’d like this new listing” without me hand-writing the joins that the graph gives for free.
TLDR
The whole thing is open source and self-hosted, which is the point: your buyers’ data lives in your own Postgres and Neo4j, not someone’s API. Call the live demo, give it a name and number, hang up, call back and give the number again. It greets you by name. Ask it to forget you, and it does.
If you take one thing from this build: memory for agents is not a longer prompt, it is a lifecycle. remember what matters, recall across sessions with a graph and not a keyword, improve so it sharpens, and forget safely. Cognee gives you those four verbs; the interesting work is deciding, per feature, which side of the reflex vs memory line it sits on.
What would you want your agent to remember about you between calls, and what would you want it to forget?
메타데이터
- post_id
- 90ef8fdc8327
- slug
- your-voice-agent-forgets-you-the-moment-you-hang-up-i-gave-it-a-memory-90ef8fdc8327
- url
- https://medium.com/@mahimairaja/your-voice-agent-forgets-you-the-moment-you-hang-up-i-gave-it-a-memory-90ef8fdc8327
- canonical_url
- https://medium.com/@mahimairaja/your-voice-agent-forgets-you-the-moment-you-hang-up-i-gave-it-a-memory-90ef8fdc8327
- author_url
- https://medium.com/@mahimairaja
- status
- ok
- fetched_at
- 2026-07-13 10:48:08