<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anand Prakash Srivastava</title>
    <description>The latest articles on DEV Community by Anand Prakash Srivastava (@anand240).</description>
    <link>https://dev.to/anand240</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4159276%2F8c746f8a-f269-4844-96e5-cee01e40a855.jpg</url>
      <title>DEV Community: Anand Prakash Srivastava</title>
      <link>https://dev.to/anand240</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand240"/>
    <language>en</language>
    <item>
      <title>My first cognee connector: syncing Basecamp into an AI agent's memory</title>
      <dc:creator>Anand Prakash Srivastava</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:24:51 +0000</pubDate>
      <link>https://dev.to/anand240/my-first-cognee-connector-syncing-basecamp-into-an-ai-agents-memory-47hk</link>
      <guid>https://dev.to/anand240/my-first-cognee-connector-syncing-basecamp-into-an-ai-agents-memory-47hk</guid>
      <description>&lt;p&gt;Basecamp is where a lot of small teams keep the boring but important stuff. The message that says when something launches. The to-do list of what is left. The comment under it saying "this needs a security review first". It is all there, just spread across a project in a way that is easy to lose track of.&lt;/p&gt;

&lt;p&gt;Basecamp was the first connector I built for Mergetober. It syncs messages, to-dos, documents and comments into &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt;, an open source memory layer for AI agents, so you can ask questions about a project instead of clicking through it. It is PR &lt;a href="https://github.com/topoteretes/cognee-community/pull/352" rel="noopener noreferrer"&gt;#352&lt;/a&gt; in cognee-community, for issue &lt;a href="https://github.com/topoteretes/cognee/issues/4707" rel="noopener noreferrer"&gt;#4707&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I use it
&lt;/h2&gt;

&lt;p&gt;I set up a small product launch across three tools to try my connectors together: discussion in Twist, tasks in Basecamp, the customer email in Mailchimp. (The &lt;a href="https://dev.to/anand240/giving-an-ai-agent-memory-of-our-twist-threads-building-a-twist-connector-for-cognee-3ic6"&gt;Twist&lt;/a&gt; and &lt;a href="https://dev.to/anand240/teaching-an-ai-agent-what-our-newsletters-said-a-mailchimp-connector-for-cognee-1hdj"&gt;Mailchimp&lt;/a&gt; connectors have their own posts.) Basecamp holds the "what is left to do" side.&lt;/p&gt;

&lt;p&gt;My project has a "Q4 launch plan" message, a "Launch tasks" to-do list with "Write release notes" and "Fix login bug", and a couple of comments. Each item becomes one document. These are from my live sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Q4 launch plan

Basecamp Message
Project: My Project
Author: Anand
Created: 2026-10-08

We will launch the mobile app on Nov 22. The payments team owns checkout.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Comment on message "Q4 launch plan"

Basecamp Comment
Project: My Project
Author: Anand
Created: 2026-10-08
On message: "Q4 launch plan"

Checkout needs a security review first.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Write release notes

Basecamp To-do
Project: My Project
Author: Anand
Created: 2026-10-08
To-do list: Launch tasks
Completed: yes
Due: 2026-10-09

Include the Q4 changes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small things there took me a while to get right. A comment on its own ("Checkout needs a security review first.") means nothing, so every comment carries its parent's title. And a to-do carries its list, whether it is done, and its due date, because "which to-dos are done?" is one of the first things you ask.&lt;/p&gt;

&lt;p&gt;The code to sync and ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_basecamp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;basecamp_source&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;basecamp_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# your account id
&lt;/span&gt;        &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My Sync (me@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Basecamp requires one
&lt;/span&gt;    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;basecamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which to-dos in Launch tasks are completed?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;basecamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my account, with Gemini as the LLM and local embeddings, I got:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer from cognee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When is the mobile app launching?&lt;/td&gt;
&lt;td&gt;"The mobile app will launch on Nov 22."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which to-dos in Launch tasks are completed?&lt;/td&gt;
&lt;td&gt;"'Write release notes' and 'Fix login bug' are completed."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What did people say about checkout?&lt;/td&gt;
&lt;td&gt;"Anand said that checkout needs a security review first and that the payments team owns it."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first answer says Nov 22 because I had just edited the message from "Nov 15" to "Nov 22" and synced again. The old date was gone from memory, which was exactly the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lnf3njhped4w6ny60tw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lnf3njhped4w6ny60tw.png" alt=" " width="798" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  One endpoint for everything
&lt;/h3&gt;

&lt;p&gt;Basecamp's API is organised by project and tool. You can walk projects, then message boards, then messages, then comments, and so on. That is a lot of requests. But there is one endpoint that lists everything of a given type across the account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /projects/recordings.json?type=Message&amp;amp;status=active&amp;amp;sort=updated_at&amp;amp;direction=desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;Message&lt;/code&gt;, &lt;code&gt;Todo&lt;/code&gt;, &lt;code&gt;Document&lt;/code&gt;, &lt;code&gt;Comment&lt;/code&gt;), &lt;code&gt;status&lt;/code&gt; (&lt;code&gt;active&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;, &lt;code&gt;trashed&lt;/code&gt;), an optional &lt;code&gt;bucket&lt;/code&gt; to limit it to some projects, and sorts newest first. The whole connector reads from this one endpoint. Paging follows only the &lt;code&gt;Link: rel="next"&lt;/code&gt; header, never page numbers I make up myself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two ways to avoid doing work
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;ETags.&lt;/strong&gt; The first page of each listing comes back with an &lt;code&gt;ETag&lt;/code&gt;. Next run, I send it back as &lt;code&gt;If-None-Match&lt;/code&gt;. If nothing changed, Basecamp answers &lt;code&gt;304 Not Modified&lt;/code&gt; with no body. Since any change bumps an item's &lt;code&gt;updated_at&lt;/code&gt; and moves it to the top of a newest-first list, an unchanged first page really does mean nothing changed for that type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An &lt;code&gt;updated_at&lt;/code&gt; cursor.&lt;/strong&gt; If something did change, I read from the top and stop as soon as I reach items older than the last run's newest timestamp (minus a five minute overlap, so a clock difference cannot make me miss something):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_recordings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;etag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse_ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stop_before&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;stop_before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both live in dlt's resource state, which is only saved when the load succeeds. A run that fails halfway leaves the state where it was, and the next run starts from the same point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deletes are where it gets interesting
&lt;/h2&gt;

&lt;p&gt;There are three ways an item can leave a project, and they need three different answers:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthejzs0qpdkl9lmrdazx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthejzs0qpdkl9lmrdazx.png" alt=" " width="799" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trashed&lt;/strong&gt; is easy. The item moves to &lt;code&gt;status=trashed&lt;/code&gt; and its &lt;code&gt;updated_at&lt;/code&gt; moves, so the next incremental run finds it in the trashed listing and yields a &lt;code&gt;_deleted&lt;/code&gt; row. dlt removes the row and cognee forgets it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Archived&lt;/strong&gt; is not a delete. I checked live: the item moves to &lt;code&gt;status=archived&lt;/code&gt; and its comments follow it. Nothing was lost, so archived items stay in memory, marked "Status: archived".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Purged&lt;/strong&gt; is the hard one. When the trash is emptied, either by hand or automatically after 25 days, the item vanishes from every listing and a GET returns 404. There is nothing left to see in an incremental run. So every 10th run (or with &lt;code&gt;full_sync=True&lt;/code&gt;) the connector does a full sweep: it lists everything that is active or archived, and anything it synced before that is not in that list was purged.&lt;/p&gt;

&lt;p&gt;These are my live runs on the account:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Change in Basecamp&lt;/th&gt;
&lt;th&gt;Result in cognee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Full sync, 5 documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Edited a message ("Nov 15" to "Nov 22")&lt;/td&gt;
&lt;td&gt;Only the changed item re-read; new text stored, old text gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Completed a to-do&lt;/td&gt;
&lt;td&gt;Stored as "Completed: yes", still in memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Created a to-do&lt;/td&gt;
&lt;td&gt;Added (6 documents)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Trashed that to-do&lt;/td&gt;
&lt;td&gt;1 deletion, the other 5 unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The bug I shipped in the first version
&lt;/h2&gt;

&lt;p&gt;The sweep had a problem that I only saw after the reviewer on my Twist PR found the same pattern there. My sweep forgot every synced item that it did not see. Now think about what a sweep looks like when you pass a wrong project id, or the token loses access to a project, or the API returns something odd that parses as an empty list. It sees nothing. So it forgets everything.&lt;/p&gt;

&lt;p&gt;I wrote a test for it on the old code: 12 synced items, a sweep with a wrong project filter, and 12 of 12 forgotten in one run. That is the whole dataset gone because of a typo.&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a sweep finds more than half of what it synced newly gone, and at least 10 items, it holds those deletes back and reports them as &lt;code&gt;held_back&lt;/code&gt;. The very next run sweeps again (it does not wait for the 10th run), forgets the held items that are still gone, and keeps the ones that came back.&lt;/li&gt;
&lt;li&gt;Items in the trash are never held. The trashed listing is direct evidence that the user deleted them.&lt;/li&gt;
&lt;li&gt;A listing page that is not a JSON list, or a &lt;code&gt;next&lt;/code&gt; link that points back at the same page, stops the run with an error instead of being read as "nothing here".
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# An odd 200 must never read as an empty listing: the sweep would
&lt;/span&gt;    &lt;span class="c1"&gt;# forget everything it holds.
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BasecampError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Basecamp: unexpected body for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; nothing was synced or forgotten.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same review also changed how I handle &lt;code&gt;Retry-After&lt;/code&gt;. My client used to sleep for whatever Basecamp asked, so &lt;code&gt;Retry-After: 3600&lt;/code&gt; meant a sync sleeping for an hour while cognee held its staging lock, and &lt;code&gt;nan&lt;/code&gt; crashed &lt;code&gt;time.sleep&lt;/code&gt;. Now any wait over 60 seconds stops the run as a rate limit so the next sync can retry, and a zero, negative or unreadable value uses exponential backoff. Every one of these fixes has a test that fails when I revert the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth, and some small things the API taught me
&lt;/h2&gt;

&lt;p&gt;Basecamp uses OAuth through 37signals Launchpad. &lt;code&gt;examples/authorize.py&lt;/code&gt; does the one-time browser step and saves the tokens. Access tokens expire after two weeks, so the client can take a refresh token with the client id and secret and refreshes once on a 401. (I found that out the practical way: the token I tested with expired while I was writing this post.)&lt;/p&gt;

&lt;p&gt;Other things I learned from the live API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basecamp's docs ask for a &lt;code&gt;User-Agent&lt;/code&gt; on every request that names your app and a way to contact you, so the connector makes you pass one.&lt;/li&gt;
&lt;li&gt;An expired trial or suspended account answers 404 with a &lt;code&gt;Reason: Account Inactive&lt;/code&gt; header. I turned that into its own error, because "not found" is a confusing message for "your trial ended".&lt;/li&gt;
&lt;li&gt;Completed to-dos are already in the default listing with &lt;code&gt;completed: true&lt;/code&gt;, so no extra request is needed.&lt;/li&gt;
&lt;li&gt;Message titles arrive with a trailing newline.&lt;/li&gt;
&lt;li&gt;The token is never put in an error message. URLs in errors are redacted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;The tests run against an in-memory fake of the Basecamp API that copies what the live account did: ETags and 304s, trash, purge, archive, &lt;code&gt;Link&lt;/code&gt; paging. One test runs through cognee itself (with the LLM mocked) and checks that both a trashed item and a purged item actually leave the knowledge graph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2tzw8leba026emvik7sy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2tzw8leba026emvik7sy.png" alt=" " width="800" height="148"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only active projects are read (the API default). Archiving a whole project removes its items on the next sweep.&lt;/li&gt;
&lt;li&gt;Card tables, schedules, chat, uploads and check-in answers are not synced yet.&lt;/li&gt;
&lt;li&gt;A purged item is only noticed on the next sweep, every 10th run by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The code is in &lt;code&gt;packages/connector/basecamp/&lt;/code&gt; in &lt;a href="https://github.com/topoteretes/cognee-community/pull/352" rel="noopener noreferrer"&gt;cognee-community&lt;/a&gt;. Register an app at launchpad.37signals.com, run &lt;code&gt;examples/authorize.py&lt;/code&gt;, then &lt;code&gt;examples/example.py&lt;/code&gt;. If your team lives in Basecamp, I would love to hear what you ask it first.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>opensource</category>
      <category>mergetober</category>
    </item>
    <item>
      <title>Teaching an AI agent what our newsletters said: a Mailchimp connector for cognee</title>
      <dc:creator>Anand Prakash Srivastava</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:12:55 +0000</pubDate>
      <link>https://dev.to/anand240/teaching-an-ai-agent-what-our-newsletters-said-a-mailchimp-connector-for-cognee-1hdj</link>
      <guid>https://dev.to/anand240/teaching-an-ai-agent-what-our-newsletters-said-a-mailchimp-connector-for-cognee-1hdj</guid>
      <description>&lt;p&gt;Every company that sends newsletters has the same small problem. Someone asks "when did we tell customers the new pricing starts?" and the answer is sitting in a campaign from two months ago. Nobody remembers which one. You end up scrolling through Mailchimp's campaign list and opening emails one by one.&lt;/p&gt;

&lt;p&gt;For Mergetober I wrote a Mailchimp connector for &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt;, an open source memory layer for AI agents. It pulls your campaigns (and, if you want, the notes you keep on contacts) into cognee, so an agent can answer questions about what you actually sent. It is PR &lt;a href="https://github.com/topoteretes/cognee-community/pull/428" rel="noopener noreferrer"&gt;#428&lt;/a&gt; in cognee-community, for issue &lt;a href="https://github.com/topoteretes/cognee/issues/4784" rel="noopener noreferrer"&gt;#4784&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Most of the work turned out not to be "call the API". It was getting clean text out of an email, and working out when a campaign has changed when Mailchimp does not tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I use it
&lt;/h2&gt;

&lt;p&gt;I set up a small product launch across three tools to try this properly: the discussion in Twist, the tasks in Basecamp, and the customer announcement in Mailchimp. I wrote connectors for all three (the &lt;a href="https://dev.to/anand240/giving-an-ai-agent-memory-of-our-twist-threads-building-a-twist-connector-for-cognee-3ic6"&gt;Twist&lt;/a&gt; and Basecamp posts are separate). The Mailchimp part is the customer facing side: what did we actually &lt;em&gt;tell&lt;/em&gt; people, and when.&lt;/p&gt;

&lt;p&gt;My account has a campaign called "Checkout launch(march)" whose email says the new checkout ships on 15 March and that the payments team owns it. After a sync this is the document cognee gets. Real output from my account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mailchimp campaign: Checkout launch(march)
Subject: New checkout is coming
Audience: Anand Test Workspace
Status: draft; a send started on 2026-10-16 was stopped

Checkout launch

The new checkout flow ships on 15 March. The payments team owns it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That status line is real too, and there is a story behind it further down.&lt;/p&gt;

&lt;p&gt;Using it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_mailchimp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mailchimp_source&lt;/span&gt;

&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.mailchimp_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;mailchimp_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include_unsent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include_notes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailchimp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;When does the new checkout ship, and who owns it?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailchimp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this test I used Gemini for extraction and answers, with local embeddings. I added a second draft, "Pricing update", through the API so I could test a hand-coded HTML campaign too. Then I asked questions, deleted that draft, synced again and asked the same question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer from cognee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sync (2 campaigns + 1 note)&lt;/td&gt;
&lt;td&gt;When does the new checkout flow ship, and who owns it?&lt;/td&gt;
&lt;td&gt;"The new checkout flow ships on 15 March (2026) and is owned by the payments team."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;When does the new pricing start and what will the Starter plan cost?&lt;/td&gt;
&lt;td&gt;"The new pricing starts on 1 April, and the Starter plan will cost 9 EUR a month."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;What does the contact prefer to hear about?&lt;/td&gt;
&lt;td&gt;"Updates about the checkout redesign."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete "Pricing update", sync again&lt;/td&gt;
&lt;td&gt;When does the new pricing start and what will the Starter plan cost?&lt;/td&gt;
&lt;td&gt;"...there is no information about when the new pricing starts or what the Starter plan will cost."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After the delete I also checked the graph directly. There was no node left for Starter, pricing, 9 EUR or April, while "new checkout flow" and "payments team" were still there. That is the behaviour I wanted: deleting a campaign in Mailchimp should make the agent forget it, not just hide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it is put together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rh5la2efdl6kbxxjxv2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rh5la2efdl6kbxxjxv2.png" alt=" " width="798" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One document per campaign, keyed &lt;code&gt;campaign:&amp;lt;id&amp;gt;&lt;/code&gt;: title, subject, preview text, audience, sent date or status, then the email text, plus the archive URL.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;include_notes=True&lt;/code&gt;, one document per contact note. The note is tied to the contact's &lt;em&gt;name&lt;/em&gt;. The email address is only included if you also pass &lt;code&gt;include_contact_email=True&lt;/code&gt;, and address, phone and birthday fields are never requested at all.&lt;/li&gt;
&lt;li&gt;Every row carries a node set &lt;code&gt;mailchimp:&amp;lt;audience_id&amp;gt;&lt;/code&gt;, so you can scope recall to one audience. I used the audience id, not the name, so renaming an audience does not split its documents in two.&lt;/li&gt;
&lt;li&gt;It is a dlt source, loaded with &lt;code&gt;write_disposition="merge"&lt;/code&gt;. Deletes are rows with &lt;code&gt;_deleted: True&lt;/code&gt;, which dlt removes and cognee then forgets.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The API key decides the host
&lt;/h3&gt;

&lt;p&gt;Mailchimp keys look like &lt;code&gt;&amp;lt;32 hex characters&amp;gt;-us22&lt;/code&gt;. The part after the dash is the data centre, and the API host is &lt;code&gt;https://us22.api.mailchimp.com/3.0&lt;/code&gt;. I build the host only from that suffix, after checking the key's shape, so user input never ends up in a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{32}-([a-z]{2,}[0-9]+)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;data_centre&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_API_KEY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;That does not look like a Mailchimp API key &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(32 hex characters, a dash and the data centre, e.g. ...-us22).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key never goes into a log line or an error message. Error messages name the endpoint with ids replaced (&lt;code&gt;campaigns/{id}/content&lt;/code&gt;), so a stack trace does not leak which campaign or contact it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the text the author wrote
&lt;/h2&gt;

&lt;p&gt;I assumed &lt;code&gt;plain_text&lt;/code&gt; would be the easy answer. It is not. Campaigns made in Mailchimp's current builder have no &lt;code&gt;plain_text&lt;/code&gt; at all until they are sent. And after sending, this is what you get:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkzjudm72zgpbhxdxk4h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkzjudm72zgpbhxdxk4h.png" alt=" " width="799" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One sentence of content, surrounded by "View this email in your browser", two "Logo" lines, a footer, and unresolved merge tags like &lt;code&gt;*|UNSUB|*&lt;/code&gt; and &lt;code&gt;*|LIST:COMPANY|*&lt;/code&gt;. Send that to an LLM for graph extraction and you are asking for entities like "Logo" and "mailing address" next to your real content.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;archive_html&lt;/code&gt; turned out to be better. It exists before sending, and its merge tags are already resolved. So the connector parses &lt;code&gt;archive_html&lt;/code&gt; with Python's &lt;code&gt;HTMLParser&lt;/code&gt; and skips three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the hidden preheader (&lt;code&gt;display:none&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;the footer block (Mailchimp marks it with classes like &lt;code&gt;mceFooterSection&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;the "View this email in your browser" link, which points to &lt;code&gt;campaign-archive.com&lt;/code&gt; before sending and &lt;code&gt;mailchi.mp&lt;/code&gt; after.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ay44j35h1zfh5u0jt9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ay44j35h1zfh5u0jt9.png" alt=" " width="800" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tests use two real &lt;code&gt;archive_html&lt;/code&gt; captures from my account (ids replaced), so if Mailchimp changes its template markup, a test should notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when a campaign changed
&lt;/h2&gt;

&lt;p&gt;This was the real design problem. Mailchimp campaigns have no "updated at" field. The listing can filter by send time or create time, and the issue suggested &lt;code&gt;since_send_time&lt;/code&gt; as a cursor. I tried it on the live account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It returned 0 campaigns while two drafts existed.&lt;/li&gt;
&lt;li&gt;It missed a rename of a sent campaign.&lt;/li&gt;
&lt;li&gt;It can never show you a delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I dropped it. Every run lists every campaign (1000 per request, asking only for the fields I need), and for each one compares a small set of fields with the previous run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;recipients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recipients&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SENT_STATUSES&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject_line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preview_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sent campaign cannot be edited, so its text is fetched once and again only if one of these fields moves. Drafts are trickier: I edited a draft's body and &lt;em&gt;no field changed at all&lt;/em&gt;. So drafts (opt-in with &lt;code&gt;include_unsent=True&lt;/code&gt;, because unfinished drafts are often full of template placeholder text) have their content read every run and compared by a content hash. Notes are similar: adding or deleting a note never moves the contact's &lt;code&gt;last_changed&lt;/code&gt;, so notes are read every run, but only for contacts that have a &lt;code&gt;last_note&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two live runs back to back, today:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdijs2zk3067zxakkrje1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdijs2zk3067zxakkrje1.png" alt=" " width="799" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The send that got halted
&lt;/h2&gt;

&lt;p&gt;Here is the story behind that status line. While I was testing, I sent the checkout campaign to my own test audience. Mailchimp put the new account under review and halted the send. On the API side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the status went from &lt;code&gt;sending&lt;/code&gt; back to &lt;code&gt;save&lt;/code&gt;, but &lt;code&gt;send_time&lt;/code&gt; was kept,&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;archive_html&lt;/code&gt; stopped being HTML. It became plain text with one &lt;code&gt;&amp;lt;br /&amp;gt;&lt;/code&gt; per line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My HTML parser let the whole footer through on that run, because there were no footer classes left to match. I would never have thought to test for this. Now the connector detects an &lt;code&gt;archive_html&lt;/code&gt; with no tags other than &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;, treats it as plain text and trims it like &lt;code&gt;plain_text&lt;/code&gt;, and the status line says "draft; a send started on 2026-10-16 was stopped" so the agent does not claim it was sent. There is a test built from that exact capture.&lt;/p&gt;

&lt;p&gt;These were my live runs on the account:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Change in Mailchimp&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;First sync (campaign in &lt;code&gt;sending&lt;/code&gt;, one contact note)&lt;/td&gt;
&lt;td&gt;1 campaign + 1 note, without the email address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;0 changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Send halted by Mailchimp; a new empty draft created&lt;/td&gt;
&lt;td&gt;both re-read (this is where the footer leaked; fixed and rerun)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Template applied to the new draft&lt;/td&gt;
&lt;td&gt;1 changed (content hash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Draft text edited&lt;/td&gt;
&lt;td&gt;1 changed, next run 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Halted campaign renamed&lt;/td&gt;
&lt;td&gt;1 changed, next run 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Draft deleted&lt;/td&gt;
&lt;td&gt;1 deleted, row gone; next run 0 deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Forgetting, carefully
&lt;/h2&gt;

&lt;p&gt;A campaign or note missing from a complete listing becomes a &lt;code&gt;_deleted&lt;/code&gt; row. Three things stop that from going wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 404 is never a delete.&lt;/strong&gt; &lt;code&gt;/reports/{id}&lt;/code&gt; keeps answering for a deleted campaign, so it is never used to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offset paging can skip an item.&lt;/strong&gt; If a campaign is deleted between page 1 and page 2, everything shifts and one item can be missed for that run. So if a listing's total changed while it was being read, the run forgets nothing and the next run decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mass deletes wait one run.&lt;/strong&gt; If more than half of what was synced is suddenly gone (and at least 10 items), which is what a wrong audience filter looks like, the deletes are held and reported as &lt;code&gt;held_back&lt;/code&gt;. The next run forgets the ones still gone and keeps the ones that came back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not get that last one right the first time. My Twist connector had the same logic, and the reviewer there found that a batch was only confirmed if the next run saw &lt;em&gt;exactly&lt;/em&gt; the same items gone. Delete a couple more campaigns in between and the batch was held again, forever. I fixed it in Twist and then applied the same fix here, along with the &lt;code&gt;Retry-After&lt;/code&gt; edge cases from the same review (&lt;code&gt;0&lt;/code&gt;, negative and &lt;code&gt;nan&lt;/code&gt; now use backoff, and any wait over 60 seconds stops the run as a rate limit, since cognee holds its staging lock while a source runs). Each fix has a test that fails when the fix is reverted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;The tests run against an in-memory fake of the Mailchimp API built from what the live account returned. No key needed. One test goes through cognee itself, with the LLM mocked, and checks that a deleted campaign and a deleted note leave both the Data records and the graph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexsjz2rr6ma8ocjimxpc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexsjz2rr6ma8ocjimxpc.png" alt=" " width="800" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every run lists every campaign. With drafts on, each draft's content is fetched every run; with notes on, one request per contact that has notes.&lt;/li&gt;
&lt;li&gt;Images are dropped. Automations, landing pages, templates and reports are not synced.&lt;/li&gt;
&lt;li&gt;Archived campaigns follow Mailchimp's documented &lt;code&gt;archived&lt;/code&gt; status and are covered by tests, but I could not check them live. Mailchimp only lets you archive sent campaigns, and sending was disabled on my account while it was under review.&lt;/li&gt;
&lt;li&gt;Anyone who can read the cognee dataset can read the synced text and notes, so keep it in its own dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The code is in &lt;code&gt;packages/connector/mailchimp/&lt;/code&gt; in &lt;a href="https://github.com/topoteretes/cognee-community/pull/428" rel="noopener noreferrer"&gt;cognee-community&lt;/a&gt;. Create a key under Account &amp;amp; billing, Extras, API keys, then run &lt;code&gt;examples/example.py&lt;/code&gt;. If you have years of newsletters, I am curious what you end up asking it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>mergetober</category>
    </item>
    <item>
      <title>Giving an AI agent memory of our Twist threads: building a Twist connector for cognee</title>
      <dc:creator>Anand Prakash Srivastava</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:02:20 +0000</pubDate>
      <link>https://dev.to/anand240/giving-an-ai-agent-memory-of-our-twist-threads-building-a-twist-connector-for-cognee-3ic6</link>
      <guid>https://dev.to/anand240/giving-an-ai-agent-memory-of-our-twist-threads-building-a-twist-connector-for-cognee-3ic6</guid>
      <description>&lt;p&gt;Twist is built for long, slow conversations. Someone opens a thread with a proposal, people reply over two or three days, and somewhere around the fifth comment the actual decision gets made. That is great for a team, but it is terrible if you want to ask "what did we decide about X?" later. Search finds the thread. It does not find the answer.&lt;/p&gt;

&lt;p&gt;So for Mergetober I built a Twist connector for &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt;, an open source memory layer for AI agents. You point it at a Twist workspace, it pulls threads and channels into cognee, and then you can ask questions in plain language. It is PR &lt;a href="https://github.com/topoteretes/cognee-community/pull/364" rel="noopener noreferrer"&gt;#364&lt;/a&gt; in cognee-community, for issue &lt;a href="https://github.com/topoteretes/cognee/issues/4736" rel="noopener noreferrer"&gt;#4736&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is how I use it, how it works inside, and the bugs a reviewer found that I would never have found on my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually use it
&lt;/h2&gt;

&lt;p&gt;To try this properly I set up a small product launch the way a lot of small teams actually run one: the discussion in Twist, the tasks in Basecamp, and the announcement email in Mailchimp. I wrote connectors for all three during Mergetober (the Basecamp and Mailchimp posts are separate), and the point is to sync them into one cognee setup and ask about the launch without opening three apps.&lt;/p&gt;

&lt;p&gt;In Twist I have a &lt;code&gt;#launch&lt;/code&gt; channel with a thread called "Checkout redesign". The opening post says "Final call: option B ships first, on 15 March." and the comments confirm it. After a sync, this is the document cognee receives for that thread. This is real output from my workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Twist thread: Checkout redesign
Channel: #launch in Anand Test
Started by Anand Prakash Srivastava on 2026-10-10

Final call: option B ships first, on 15 March.

Anand Prakash Srivastava on 2026-10-10:
_Final call: option B ships first. Confirmed by QA and design._

Anand Prakash Srivastava on 2026-10-11:
_Ship date confirmed._
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_twist&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;twist_source&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.twist_tokens.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;twist_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;254427&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;852087&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;twist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What did the team decide about the checkout redesign?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;twist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the results I got on my workspace. I ran this one without an LLM key, in cognee's keyless mode, so recall returns the best matching passage instead of a written answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Top result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sync #launch and #Twist&lt;/td&gt;
&lt;td&gt;What did the team decide about the checkout redesign?&lt;/td&gt;
&lt;td&gt;"Checkout redesign": "Final call: option B ships first, on 15 March."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;What to do next in Twist?&lt;/td&gt;
&lt;td&gt;the "What to do next" thread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete "What to do next" in Twist, sync again&lt;/td&gt;
&lt;td&gt;What to do next in Twist?&lt;/td&gt;
&lt;td&gt;"Get started in 4 steps". The deleted thread is not returned any more&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row matters to me more than the first two. If memory keeps things you deleted, you stop trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design in one picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn743dr29hwpkcpnncok9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn743dr29hwpkcpnncok9.png" alt=" " width="798" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few decisions shaped everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One document per thread, not per comment.&lt;/strong&gt; My first idea was to send each comment as its own document. But the decision is usually in comment five, and comment five only makes sense next to the opening post. So a thread becomes one document: title, channel, opening post, then every comment in order with author and date. Each channel also gets a small document of its own. Every row is tagged with a node set like &lt;code&gt;twist:&amp;lt;workspace_id&amp;gt;:&amp;lt;channel_id&amp;gt;&lt;/code&gt;, so you can scope recall to one channel later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It goes through dlt, with &lt;code&gt;merge&lt;/code&gt;.&lt;/strong&gt; cognee accepts dlt sources directly. The connector is a dlt source that yields rows keyed by &lt;code&gt;thread:&amp;lt;id&amp;gt;&lt;/code&gt;. When a thread disappears, it yields &lt;code&gt;{"id": "thread:123", "_deleted": True}&lt;/code&gt; and dlt removes the row. cognee's orphan cleanup then removes the document and its part of the graph. One thing caught me early: &lt;code&gt;remember()&lt;/code&gt; defaults to &lt;code&gt;write_disposition="replace"&lt;/code&gt;, which quietly breaks both incremental sync and deletes. You have to pass &lt;code&gt;"merge"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Figuring out what "changed" means in Twist
&lt;/h2&gt;

&lt;p&gt;This took longer than writing the code. Twist's docs give you &lt;code&gt;last_updated_ts&lt;/code&gt; and &lt;code&gt;comment_count&lt;/code&gt; on a thread, and my plan on the issue used those two. Then I tested it against a real workspace, one change at a time, and wrote down what moved:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I did in Twist&lt;/th&gt;
&lt;th&gt;&lt;code&gt;version&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;last_updated_ts&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Anything on the thread?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New comment&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit the opening post&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete a comment&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit the latest comment&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit an &lt;strong&gt;earlier&lt;/strong&gt; comment&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;nothing at all&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bookmark the thread&lt;/td&gt;
&lt;td&gt;moves&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes, but the text did not change&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So &lt;code&gt;last_updated_ts&lt;/code&gt; misses most edits. There is a &lt;code&gt;version&lt;/code&gt; field that catches nearly everything, but it is not in the documented thread object. It is in the API response though, so the connector compares four values per thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_updated_ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;sweep&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unchanged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bookmarking moves &lt;code&gt;version&lt;/code&gt; too, even though nothing in the text changed. To avoid sending that to cognee, each rendered row gets a SHA-256 hash, and if the hash matches last run, the row is skipped.&lt;/p&gt;

&lt;p&gt;The earlier comment edit is the annoying one. Nothing on the thread moves, so no cheap check can see it. The connector re-reads every thread's comments on the first run and every 10th run after that (&lt;code&gt;full_sync_every&lt;/code&gt;), or when you pass &lt;code&gt;full_sync=True&lt;/code&gt;. It is not instant, and the README says so.&lt;/p&gt;

&lt;p&gt;Here is what two runs back to back look like on my workspace today. First run sends everything, second run sends nothing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9j4lus55dk62hgbcsfqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9j4lus55dk62hgbcsfqa.png" alt=" " width="799" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When I was testing, I went through the cases one by one on the live workspace:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Change in Twist&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;First sync&lt;/td&gt;
&lt;td&gt;15 threads + 4 channels, the archived channel skipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;0 changed, 15 unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;New comment on one thread, another thread deleted&lt;/td&gt;
&lt;td&gt;1 changed, 1 deleted, 13 unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Opening post edited&lt;/td&gt;
&lt;td&gt;1 changed (caught by &lt;code&gt;version&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Thread bookmarked&lt;/td&gt;
&lt;td&gt;0 changed (content hash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Latest comment edited&lt;/td&gt;
&lt;td&gt;1 changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Earlier comment edited&lt;/td&gt;
&lt;td&gt;normal run: 0 changed. &lt;code&gt;full_sync=True&lt;/code&gt;: 1 changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some other things the live API showed me that are not obvious from the docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A deleted comment does not disappear. It stays in &lt;code&gt;comments/get&lt;/code&gt; with &lt;code&gt;deleted: true&lt;/code&gt; and the content &lt;code&gt;"-"&lt;/code&gt;. If you do not filter it, your memory gets a line that says "-".&lt;/li&gt;
&lt;li&gt;Renaming, moving or closing a thread adds a system message as a comment. Those are skipped too.&lt;/li&gt;
&lt;li&gt;A thread's &lt;code&gt;is_archived&lt;/code&gt; is not an archive. It is your own Inbox "done" state. I almost used it to drop threads.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;comments/get&lt;/code&gt; without a range returns the &lt;em&gt;last&lt;/em&gt; N comments, not the first. So comments are read in explicit &lt;code&gt;from_obj_index&lt;/code&gt; / &lt;code&gt;to_obj_index&lt;/code&gt; windows of 500.&lt;/li&gt;
&lt;li&gt;A rejected token comes back as HTTP 403 with error code 200, not 401.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I underestimated: not deleting things by mistake
&lt;/h2&gt;

&lt;p&gt;Forget-on-delete sounds simple. A thread missing from the listing gets a &lt;code&gt;_deleted&lt;/code&gt; row. But "missing from the listing" is also exactly what a wrong channel id, a token that lost access, or a half-broken API response looks like. One bad run and the whole dataset is gone.&lt;/p&gt;

&lt;p&gt;So there are a few rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 404 is never a delete on its own. A deleted thread answers 404, but Twist uses the same error for something the token can no longer see.&lt;/li&gt;
&lt;li&gt;Any API error stops the run before a single tombstone is written. dlt only saves state when the load succeeds, so the next run just retries.&lt;/li&gt;
&lt;li&gt;If a run finds more than half of what it synced newly gone, and at least 10 items, it holds those back and reports them as &lt;code&gt;held_back&lt;/code&gt;. The next run forgets the ones that are still gone and keeps the ones that came back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finov92clm1ppm6wsdb5y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finov92clm1ppm6wsdb5y.png" alt=" " width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core of it is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;present&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending_delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="n"&gt;confirmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;large&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MASS_DELETE_MIN_ITEMS&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_delete_ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;large&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;allow_mass_delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending_delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;
    &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;held_back&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;confirmed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first version raised an error instead of holding. The reviewer pointed out that a scheduled sync would then stop forever until someone noticed. Holding for one run fixes that without a manual step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What code review taught me
&lt;/h2&gt;

&lt;p&gt;goran-radonic reviewed this PR in three rounds. Every round found something real, and most of it was stuff my tests could not have caught because I did not think of the case. A few I remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rolling deletes were held forever.&lt;/strong&gt; My hold-back only confirmed a batch if the next run found &lt;em&gt;exactly&lt;/em&gt; the same items gone. If you deleted a few more threads before the next run, the set changed and the whole batch was held again. Forever. The fix is the &lt;code&gt;confirmed&lt;/code&gt; / &lt;code&gt;fresh&lt;/code&gt; split above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An archived channel could be lost.&lt;/strong&gt; If you ran once with a wrong channel list, the mark that said "this archived channel was synced while active" got dropped, and the next normal run forgot all its threads. He wrote out the scenario run by run, and I turned it into a test that went through real dlt and SQLite. Before the fix it deleted 13 rows. After, 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; edge cases.&lt;/strong&gt; I already stopped the run on any wait over 60 seconds, because cognee holds its staging lock while a source runs. But &lt;code&gt;Retry-After: 0&lt;/code&gt; made the client retry back to back with no wait, &lt;code&gt;nan&lt;/code&gt; slipped through, and &lt;code&gt;inf&lt;/code&gt; was not treated like a long wait. Now 0, negative and &lt;code&gt;nan&lt;/code&gt; fall back to exponential backoff, and &lt;code&gt;inf&lt;/code&gt; stops the run with &lt;code&gt;TwistRateLimitError&lt;/code&gt; like any other long wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stats from the previous run leaked.&lt;/strong&gt; The counters reset in the wrong place, so a run that failed early reported the last run's numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the last round I did something I will keep doing: I reverted each of the 9 changes on its own and checked that at least one test went red. If a test still passes with the fix removed, it is not testing the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The tests run against an in-memory fake of the Twist API that copies the behaviour I saw live: &lt;code&gt;version&lt;/code&gt; bumps, deleted comments with &lt;code&gt;"-"&lt;/code&gt;, system messages, archived channels, 404s, comment windows. No token needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkge8tema5bwh41pp5s9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkge8tema5bwh41pp5s9.png" alt=" " width="800" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is also one test that goes through cognee itself (&lt;code&gt;add&lt;/code&gt; + &lt;code&gt;cognify&lt;/code&gt; with the LLM mocked) and checks that a deleted thread actually leaves the knowledge graph while the others keep their ids. It goes red if you turn off either the tombstone or dlt's &lt;code&gt;hard_delete&lt;/code&gt; hint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits I am honest about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every run lists all threads of the selected channels. That is one request per 500 threads, plus one per changed thread.&lt;/li&gt;
&lt;li&gt;An edit to an earlier comment shows up only on the next sweep.&lt;/li&gt;
&lt;li&gt;On Twist's free plan, the API hides comments older than one month. When an old thread is re-rendered, those comments drop out of memory too.&lt;/li&gt;
&lt;li&gt;Images are dropped. Attachments are listed by name and link but not downloaded.&lt;/li&gt;
&lt;li&gt;Direct messages are never read. That one is on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The connector lives in &lt;code&gt;packages/connector/twist/&lt;/code&gt; in &lt;a href="https://github.com/topoteretes/cognee-community/pull/364" rel="noopener noreferrer"&gt;cognee-community&lt;/a&gt;. The README walks through creating a Twist integration and getting a token with read-only scopes (&lt;code&gt;workspaces:read,channels:read,threads:read,comments:read&lt;/code&gt;), and &lt;code&gt;examples/example.py&lt;/code&gt; runs the whole thing.&lt;/p&gt;

&lt;p&gt;If you use Twist and try it, I would like to know which questions you end up asking it. That tells me more than any test.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>mergetober</category>
      <category>opensource</category>
    </item>
    <item>
      <title>SniffDog: I built my job-hunting friend a sniffer dog that sniffs out malware in fake recruiter repos</title>
      <dc:creator>Anand Prakash Srivastava</dc:creator>
      <pubDate>Sun, 04 Oct 2026 22:25:22 +0000</pubDate>
      <link>https://dev.to/anand240/sniffdog-i-built-my-job-hunting-friend-a-sniffer-dog-that-sniffs-out-malware-in-fake-recruiter-355m</link>
      <guid>https://dev.to/anand240/sniffdog-i-built-my-job-hunting-friend-a-sniffer-dog-that-sniffs-out-malware-in-fake-recruiter-355m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SniffDog checks a take-home coding assignment before you run it.&lt;/strong&gt; It reads the repo, never executes it, and tells you in plain English whether it's safe to open.&lt;/p&gt;

&lt;p&gt;I built it for my friend Mayank Raj. Earlier this year, Mayank, a final-year student hunting for his first developer job, got a LinkedIn message from a "recruiter" offering a remote junior developer role. The recruiter sent a GitHub repo and asked him to open it in VS Code and review the code. On the next call, he was asked to run the project live, so he ran &lt;code&gt;npm install&lt;/code&gt; and &lt;code&gt;npm run dev&lt;/code&gt; without reading it. Everything worked, and the call went well.&lt;/p&gt;

&lt;p&gt;A few days later, he got a "new login" alert on his email and found an unknown session on his GitHub account. Malware had copied his saved browser passwords. He spent the weekend changing passwords, revoking tokens and wiping his laptop. The recruiter's profile was gone.&lt;/p&gt;

&lt;p&gt;SniffDog is what I wish he'd had before he opened that folder.&lt;/p&gt;

&lt;p&gt;What happened to Mayank wasn't bad luck. Microsoft has tracked the &lt;a href="https://microsoft.com/en-us/security/blog/2026/03/11/contagious-interview-malware-delivered-through-fake-developer-job-interviews" rel="noopener noreferrer"&gt;Contagious Interview campaign&lt;/a&gt; since at least December 2022: fake recruiters send coding exercises that run malware through dependency installs and repository tasks. In 2026, &lt;a href="https://www.elastic.co/security-labs/contagious-interview-malware-svg-steganography" rel="noopener noreferrer"&gt;Elastic Security Labs&lt;/a&gt; found the same trick aimed at their own community Slack, with the payload hidden inside SVG images and obfuscated JavaScript. One developer &lt;a href="https://theregister.com/2025/10/20/ai_prompt_saved_developer" rel="noopener noreferrer"&gt;told The Register&lt;/a&gt; he was "30 seconds away" from running one.&lt;/p&gt;

&lt;p&gt;The trap fires the moment you run &lt;code&gt;npm install&lt;/code&gt;, open the folder in VS Code, or start the dev server. A normal AI coding agent asked to "get this running" would do exactly that. SniffDog does the opposite: it looks first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sniffdog https://github.com/recruiter/assignment &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--company&lt;/span&gt; &lt;span class="s2"&gt;"Acme Labs"&lt;/span&gt; &lt;span class="nt"&gt;--recruiter&lt;/span&gt; &lt;span class="s2"&gt;"Jane Doe"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You get a verdict (🟢 SAFE, 🟡 CAUTION or 🔴 DANGER) with every red flag tied to a &lt;code&gt;file:line&lt;/code&gt;, what it would do if you ran it, and what to do next.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/1zoa5F4wANQ" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Narration generated with ElevenLabs. The terminal output in the video is real SniffDog output on the repo's harmless demo assignment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The repo ships a harmless demo assignment that copies the shape of real attacks: an install script that reaches the internet, a VS Code task that runs on folder open, JavaScript hidden inside a font file, and an obfuscated base64 payload. Every payload only prints "hello". Here is SniffDog reading it (trimmed):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 DANGER
Static checks found 13 suspicious signal(s) in this repository.

Code snippets:
  - lib/config.js:7 (encoded-code) — unwrapped from base64:
    console.log('hello'); /* … */
    Gemma: The code prints 'hello' to the console.
  - package.json:6 (package-script):
    node scripts/setup.js &amp;amp;&amp;amp; curl -s https://example.com &amp;gt; /dev/null
    Gemma: The code first executes the `setup.js` script
           using `node`, then fetches data from a URL
           and discards it.
  - public/fonts/inter.woff:1 (disguised-asset):
    console.log('hello from a harmless imitation font');
    Gemma: The code prints the text 'hello from a
           harmless imitation font' to the console.

memory: on
  - resembles known technique: alternate npm registry
  - resembles known technique: disguised font
  - …
tracing: on
Explainer: Gemma (local) read 3 snippets
Static checks only — nothing from the repo was executed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A clean assignment comes back safe, and the recruiter web check adds context without changing the verdict:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkdtbwp4jcd1biw1auav.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkdtbwp4jcd1biw1auav.png" alt="SniffDog SAFE verdict with the recruiter web check" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Anand-240" rel="noopener noreferrer"&gt;
        Anand-240
      &lt;/a&gt; / &lt;a href="https://github.com/Anand-240/sniffdog" rel="noopener noreferrer"&gt;
        sniffdog
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;SniffDog&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Review coding assignment repositories before you run them.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Anand-240/sniffdog/docs/images/sniffdog-cover.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FAnand-240%2Fsniffdog%2FHEAD%2Fdocs%2Fimages%2Fsniffdog-cover.png" alt="SniffDog"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Anand-240/sniffdog/actions/workflows/sniffdog.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/Anand-240/sniffdog/actions/workflows/sniffdog.yml/badge.svg" alt="SniffDog workflow"&gt;&lt;/a&gt; &lt;a href="https://github.com/Anand-240/sniffdog/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667" alt="MIT license"&gt;&lt;/a&gt; &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/64e4e70182a719335ba59d3ba0cf9c009a241550e3ead7d5e2b76a13e6d3b731/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e2d332e31322532422d626c75652e737667"&gt;&lt;img src="https://camo.githubusercontent.com/64e4e70182a719335ba59d3ba0cf9c009a241550e3ead7d5e2b76a13e6d3b731/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e2d332e31322532422d626c75652e737667" alt="Python 3.12+"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Demo video: &lt;a href="https://youtu.be/1zoa5F4wANQ" rel="nofollow noopener noreferrer"&gt;Watch SniffDog&lt;/a&gt; | DEV post: [DEV_POST_URL]&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this exists&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Fake recruiters send take-home repositories that hide malware. &lt;a href="https://www.microsoft.com/en-us/security/blog/2026/03/11/contagious-interview-malware-delivered-through-fake-developer-job-interviews/" rel="nofollow noopener noreferrer"&gt;Microsoft&lt;/a&gt; and &lt;a href="https://www.elastic.co/security-labs/contagious-interview-malware-svg-steganography" rel="nofollow noopener noreferrer"&gt;Elastic Security Labs&lt;/a&gt; have documented this Contagious Interview tactic. A trap may start during &lt;code&gt;npm install&lt;/code&gt;, when a folder opens in VS Code, or when the developer starts the app. SniffDog reads the repository before those steps and does not run its code. It was built for a friend who got hit by one of these fake assignments.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it looks like&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Anand-240/sniffdog/docs/images/sniffdog-danger.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FAnand-240%2Fsniffdog%2FHEAD%2Fdocs%2Fimages%2Fsniffdog-danger.png" alt="Danger report for the suspicious demo"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The harmless suspicious demo triggers 13 findings, with file locations, rule explanations, and selected code snippets.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Anand-240/sniffdog/docs/images/sniffdog-safe.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FAnand-240%2Fsniffdog%2FHEAD%2Fdocs%2Fimages%2Fsniffdog-safe.png" alt="Safe report for the safe demo"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The safe demo has zero findings; public recruiter search results appear separately from its verdict.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Install Python 3.12 or newer and &lt;a href="https://ollama.com/download" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; on macOS or Linux. Start Ollama, then run:&lt;/p&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;ollama pull gemma3:1b
ollama pull nomic-embed-text
git clone https://github.com/Anand-240/sniffdog.git
&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Anand-240/sniffdog" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;Python 3.12+, standard library only for the core. MongoDB and Sentry are optional extras that switch off cleanly when not configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;SniffDog is a Python CLI built around one rule: &lt;strong&gt;deterministic checks decide how dangerous a repo is; Gemma only reads the code and explains it.&lt;/strong&gt; The model can never lower the verdict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repo URL or folder
  → safe clone (no hooks, no symlinks, https only, depth 1)
  → 5 static scanners: scripts · VS Code · obfuscation · assets · deps
  → base64 payloads decoded as data (never run), max 4 KB
  → Gemma 3 (1B, local via Ollama) reads up to 5 snippets
  → MongoDB Atlas: known-technique vector search + history
  → GitHub API + SerpApi: repo age and recruiter context
  → verdict 🟢/🟡/🔴 + next steps
  (every step traced in Sentry, metadata only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it catches
&lt;/h3&gt;

&lt;p&gt;The scanners look for the tricks used in real assignment attacks: &lt;code&gt;postinstall&lt;/code&gt; scripts that call &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;.vscode/tasks.json&lt;/code&gt; with &lt;code&gt;runOn: folderOpen&lt;/code&gt;, &lt;code&gt;.woff&lt;/code&gt; and &lt;code&gt;.png&lt;/code&gt; files that are really JavaScript, code pushed 200 spaces off-screen, &lt;code&gt;_0x&lt;/code&gt; obfuscator names, raw-IP URLs, a custom &lt;code&gt;.npmrc&lt;/code&gt; registry, and typosquats like &lt;code&gt;expres&lt;/code&gt;. Each rule has a hand-written, plain-English consequence ("When you run npm install, packages come from an unknown server…").&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging Gemma: from "summarize everything" to "read this code"
&lt;/h3&gt;

&lt;p&gt;My first design asked Gemma to write the whole explanation. It went badly in three useful ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It claimed the user's system was "likely compromised", which a static scan can't know. I added a validator that rejects claims of completed harm.&lt;/li&gt;
&lt;li&gt;Next it just copied the rule messages back, so it added nothing.&lt;/li&gt;
&lt;li&gt;When I gave it only rule names, even &lt;code&gt;gemma3:4b&lt;/code&gt; explained hidden code as "may cause rendering issues with extra whitespace". The model wasn't the problem. It had no code to read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I changed its job. SniffDog now unwraps the hidden payload itself, statically, and hands Gemma the real snippet. Asked "what would this code do if it ran?", the 1B model is accurate. Every URL it mentions must appear in the snippet, or the sentence is dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging with Sentry
&lt;/h3&gt;

&lt;p&gt;Sentry tracing showed a full scan takes 5.16 s, and the local Gemma call is 3.59 s of it (~70%). That's why Gemma reads at most 5 snippets with a 30 s timeout. The trace also showed the Atlas memory lookup taking 1.52 s, mostly the network round trip, while every local scanner finished in about 1 ms.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9stbku3ei1to4f6he057.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9stbku3ei1to4f6he057.png" alt="Sentry trace: the local Gemma call takes 3.59 s of a 5.16 s scan" width="799" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two bugs surfaced along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No traces arrived.&lt;/strong&gt; The CLI exited before the SDK sent anything. Fix: flush before exit, including on errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too much data.&lt;/strong&gt; Sentry's debug log showed it auto-enabling its PyMongo and logging integrations, which can record query contents. I turned default integrations off. Traces now carry span names, timings and counts only, and a test proves no code, prompts or finding text leave the machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Other lessons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real company isn't a scam.&lt;/strong&gt; My first recruiter check flagged "Microsoft" as CAUTION, because the web is full of "Microsoft scam" warnings about impersonators. Now web results only add context ("Scammers have impersonated Microsoft before; verify on the official careers site") and can never change the verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hiding spots.&lt;/strong&gt; My coding agent once made the scanner skip a &lt;code&gt;.kilo&lt;/code&gt; folder because its own worktree lived there. That's a hole: an attacker could hide a payload in any folder SniffDog ignores. I reverted it. Skipping now needs an explicit &lt;code&gt;--exclude&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Harmless demo assignment&lt;/td&gt;
&lt;td&gt;🔴 DANGER&lt;/td&gt;
&lt;td&gt;13 of 13 planted tricks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe demo assignment&lt;/td&gt;
&lt;td&gt;🟢 SAFE&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MDN todo-react&lt;/td&gt;
&lt;td&gt;🟢 SAFE&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MDN Express Local Library&lt;/td&gt;
&lt;td&gt;🟢 SAFE&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;expressjs/generator&lt;/td&gt;
&lt;td&gt;🟡 CAUTION&lt;/td&gt;
&lt;td&gt;2 (&lt;code&gt;child_process&lt;/code&gt; in test files)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I kept the Express Generator result on purpose. Many assignments say "run &lt;code&gt;npm test&lt;/code&gt;", so code in test files that starts processes deserves a look. SniffDog has 17 unit tests, and a GitHub Actions workflow runs them plus a SniffDog scan on every PR that touches package files:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0moarbhpg8qvqrhtqjsx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0moarbhpg8qvqrhtqjsx.png" alt="GitHub Actions: SniffDog scan passed in 6 s" width="800" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;p&gt;SniffDog is a first look, not an antivirus. It reads files and matches known patterns, so a new trick can slip past it, and a SAFE verdict is never a guarantee. Package scanners like Socket and Datadog's GuardDog go deeper on npm packages; SniffDog focuses on the whole assignment repo (VS Code tasks, disguised assets, recruiter context) and explains it for someone who has never heard of a postinstall script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For this tool, a closed cloud model isn't just worse. In the safest setup it can't run at all.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works where you should test untrusted code.&lt;/strong&gt; Microsoft's own advice is to do take-home assignments in an isolated, non-persistent VM. The safest version of that VM has no network. A cloud API can't answer there; Gemma on Ollama can. I tested it with Wi-Fi off: same scan, same verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's free for the people who need it.&lt;/strong&gt; Mayank is still job hunting. He shouldn't need a paid AI subscription to check whether a recruiter is trying to rob him again. SniffDog runs on my 8 GB MacBook Air with &lt;code&gt;gemma3:1b&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A security tool should be inspectable.&lt;/strong&gt; Open model, open code, deterministic rules: anyone can read exactly why SniffDog called something dangerous, and change it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could change the model's job, not just its prompt.&lt;/strong&gt; When Gemma struggled, I could swap sizes in seconds (&lt;code&gt;gemma3:4b&lt;/code&gt; ran, but froze my laptop and took two minutes), read its raw output, and redesign its role around what a small model does well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest trade-off: the optional GitHub, SerpApi, Atlas and Sentry checks need the network. Everything that touches the code itself works offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built SniffDog with a coding agent, phase by phase, reviewing every commit and pushing back when it took shortcuts (like the &lt;code&gt;.kilo&lt;/code&gt; skip above).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; &lt;code&gt;gemma3:1b&lt;/code&gt; runs locally via Ollama and reads the hidden code SniffDog unwraps, so the explanation works on an offline, isolated machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of MongoDB Atlas:&lt;/strong&gt; Atlas Vector Search matches suspicious snippets against known attack techniques (local &lt;code&gt;nomic-embed-text&lt;/code&gt; embeddings), and Atlas stores scan history so a repeat scan shows "Scanned before on …". No source code is stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing:&lt;/strong&gt; every scan is a transaction with spans per scanner, memory and the Gemma call. Tracing found the CLI exit bug and showed ~70% of scan time is the local model. Metadata only, default integrations off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of SerpApi:&lt;/strong&gt; live web search checks the recruiter and company for impersonation reports, as context that never changes the code verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of GitHub Copilot:&lt;/strong&gt; a GitHub Actions workflow runs the tests and a SniffDog scan on every PR that touches package files, failing the check on DANGER.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs:&lt;/strong&gt; the demo video's narration was generated with ElevenLabs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>security</category>
    </item>
  </channel>
</rss>
