Picture this.
On Friday someone deletes a message from a team channel in Microsoft Teams. It had the wrong price in it. On Monday your AI assistant is still quoting that price. It sounds completely sure. It is completely wrong.
Nothing crashed. The assistant just kept a memory of a message that no longer exists.
I wanted to fix that for Teams. So for Mergetober I built a connector for Cognee. The code lives in PR for issue #4542.
What it does
Cognee turns documents into a knowledge graph that an agent can search. The connector feeds it from Teams channels. You pick the teams, and optionally the channels. Then:
- Every thread becomes one document. The root post comes first, then the replies in time order.
- Edits and deleted replies re-write the whole thread, so a conversation stays in one piece.
- Later runs read only what changed.
- Threads that are deleted are forgotten.
- Only standard channels are read. No 1:1 chats, no group chats, no private channels.
source = teams_source(team_ids=["<team id>"])
await cognee.remember(source, dataset_name="teams",
write_disposition="merge")
The app credentials come from MS_GRAPH_TENANT_ID, MS_GRAPH_CLIENT_ID and MS_GRAPH_CLIENT_SECRET. The Graph client is a separate small helper, so the SharePoint and Outlook connectors can reuse it.
Getting in
Teams has no API key you can just paste. You register an app in Microsoft Entra, give it a client secret, and add two application permissions: ChannelMessage.Read.All and Channel.ReadBasic.All. Then a tenant admin has to click "Grant admin consent". Without that click the app cannot read a single message, and a normal user cannot do it for you.
That is the real wall in this issue. The README walks through every click. It also covers the option where a team owner grants access to just their own team.
I had no tenant, so I started a Microsoft 365 Business Basic trial. It is free for a month but it asks for a card, so remember to cancel.
Finding what changed
I wanted Graph's delta query for channel messages. As far as I could tell from Microsoft's docs, delta only exists for chats and not for channels.
But the plain list of channel messages has a useful rule. Microsoft says the results are sorted by the last modified date of the whole reply chain, newest first. A new reply pushes its thread to the top.
So every run walks a channel from the top. It stops at the first thread that is older than the cursor, minus five minutes for safety. Everything below that point is never touched. With nothing changed, the fake-API tests show one page request per channel.
Catching deletes
Graph does not drop a deleted message. It keeps it in the list, with deletedDateTime set and an empty body. The connector turns that into a hard delete. dlt removes the row and Cognee's orphan cleanup removes it from the graph and the vector store.
I added safety rules because a wrong delete is worse than a missed one:
- A failed request, a 403 or a rate limit never deletes anything. The run stops and the next one starts from the same spot.
- A channel list that suddenly comes back empty forgets nothing.
- A deleted or deselected channel forgets the threads it gave, and only those.
The real test
First I tested against a fake Graph. It plugs in underneath a real HTTP session, so dlt's real sign-in, paging and retry code runs. 34 tests passed, including one that runs the whole Cognee pipeline with a mocked LLM and proves that an unchanged re-sync makes no LLM call. But a fake only proves what you thought to fake.
So I made a real team, posted real messages and ran the example.
The first sync took in 3 threads. I asked what the team had been talking about. The answer named the contract, the kickoff, and who owned it, which came from a reply. Running it again with nothing changed took 33 seconds and wrote no extraction lines to the log.
Then I edited one post and deleted another. The sync reported 2 changed thread(s), 1 deletion(s). The new wording was in the search results. The deleted post was gone from the documents and from the search chunks.
A deleted post whose replies live on
Next I posted a thread about a new freight partner in Mumbai, with one reply, and deleted only the root post.
Teams keeps showing the reply. Graph still returned it too. The connector forgot the whole thread anyway. The documents went from 3 to 2 and the graph from 32 nodes to 22. Both the freight partner and Mumbai were gone from the graph.
I thought about keeping the orphaned reply. I decided against it. A reply without its post says little by itself, and a forgetting connector should lean towards forgetting. The README says so plainly.
The junk only a real LLM could show me
My first version labelled replies like this: Reply from Akshat Gupta (2026-10-20 05:45 UTC):. Nothing in the fake tests could complain. A real model did. The graph grew entities called reply from akshat gupta at 2026-10-20 05:45 utc 1.
So I turned the labels into sentences: Akshat Gupta replied on 2026-10-20 at 05:45 UTC:. I ran the same two threads into two fresh datasets. The timestamp entities were gone both times. Some noise stayed, for example first reply in post a in one run, and the model is not deterministic. Two samples is all I have. It is better, not fixed.
What I did not test
Only a small team went through the live run, with one channel. These only have fake-API tests: paging past 50 threads, long reply chains, a real 429 from Graph, and keeping private and shared channels out. I did not try the team-owner consent route, and I only ran Python 3.12.
One more limit. If a retention policy erases a message completely instead of marking it deleted, Graph has nothing left to report, so that thread stays in memory until its channel is removed. The README says this too.
Try it
The example is in packages/connector/teams/examples/example.py. You need an Entra app with admin consent, and a team id. The team id is the group's Object ID in Entra.
If you point it at your own team I would love to hear what breaks.
Top comments (0)