A CRM holds a lot of small facts that only make sense together: this contact works for that company, this deal needs a discount, that discount needs someone's approval. An agent that can answer "what does Northwind want, and who has to sign off?" needs all of those links, and it needs to drop them again when the CRM changes.
For Mergetober I built a HubSpot connector for cognee. It syncs contacts, companies, deals, tickets and notes with the relationships between them, picks up only what changed, and forgets what is deleted or merged away.
- Pull request: topoteretes/cognee-community#376
- Issue: topoteretes/cognee#4546
Setting it up
The connector works with a HubSpot OAuth app (read scopes only) or a Service Key. examples/get_refresh_token.py gets the refresh token in one step: a browser consent, and the token is saved to a private file, never printed.
import cognee
from cognee_community_connector_hubspot import hubspot_source
await cognee.remember(
hubspot_source(objects=["contacts", "companies", "deals", "tickets", "notes"]),
dataset_name="hubspot_crm",
primary_key="id",
write_disposition="merge",
max_rows_per_table=0,
)
Remember, then forget
On a free HubSpot account I set up a company with two contacts, a deal, a ticket and a note, next to HubSpot's own sample records:
Then I synced it and asked cognee (with OpenAI as the LLM):
What discount does Northwind want on the Warehouse Fleet Renewal, who must approve it, and which company does Priya Raman work for?
Before:
Northwind wants a 12% discount if it signs before November 15, 2026. Arjun Mehta must approve it because it exceeds 10%. Priya Raman works for Northwind Robotics.
Then I synced it and asked cognee (with OpenAI as the LLM):
What discount does Northwind want on the Warehouse Fleet Renewal, who must approve it, and which company does Priya Raman work for?
Before:
Northwind wants a 12% discount if it signs before November 15, 2026. Arjun Mehta must approve it because it exceeds 10%. Priya Raman works for Northwind Robotics.
Then, in HubSpot, I deleted the note that held the discount, deleted Arjun Mehta, and renamed the company to Northwind Automation. Deleted records go to a restorable list, kept 90 days, and that list is what the connector reads:
After the next sync, the same question:
Discount: Not specified in the context. Approver: Not specified in the context. Priya Raman's company: Northwind Automation.
(The screenshots are from the repeat run, after restoring everything and deleting it again; the answers were the same both times.)
The discount lived only in the note, so it is forgotten. Arjun is gone from his own document and from the company's. And Priya, whose record I never touched, now works for the company's new name. I then restored everything from HubSpot's recycle bin, deleted it again, and both answers came back the same way.
Two decisions that changed from my plan
I didn't wrap dlt's verified HubSpot source. It's the obvious starting point, so I ran it on my account first. It's distributed through dlt init (copied into a project), not as a package a connector can depend on, and it had no cursor for CRM objects, no engagements (notes, calls, meetings), and no delete propagation: a contact I deleted was still there after a re-run. It also read every property. The connector uses the same dlt HTTP layer (RESTClient) with its own sync on top.
Documents, not tables. On the issue I proposed keeping CRM objects relational, so associations would become graph edges. Then I read how cognee's relational path works: it links rows only through database foreign keys, and dlt doesn't create those, so the association tables would have landed as unconnected rows. Writing the linked records into each document by name gives cognify the relationships directly.
How relationships become a graph
Each record is one document that names what it is linked to:
Contact: Priya Raman <priya@northwind.example.com>
Job title: Head of Operations
Companies: Northwind Robotics (northwind-robotics.example)
Deals: Warehouse Fleet Renewal (48000, Appointment Scheduled)
Tickets: Robot arm calibration fails after update (New)
cognify turns those names into entities, so one company mentioned in many documents becomes one node in the graph. That is also why a rename has to re-render every document that names the company, not just the company itself.
The tricky cases
Most of the work went into things that only show up on a real account:
- Deleted records drop their associations. When a contact is deleted, HubSpot no longer lists her as linked to her company, so reading the company again shows nothing to update. The connector keeps its own index of which documents name which records, so the company's document drops her name too.
-
Merged records never show up as deleted. The merged-away id only appears in the survivor's
hs_merged_object_ids, so the connector tombstones it from there. - HubSpot touches records on its own. After I deleted the note, HubSpot recalculated the deal's activity fields a few minutes later. Every touch re-rendered the contact and company linked to the deal, with identical content. Now only a changed label or a changed set of links refreshes linked documents.
- Search stops at 10,000 results. A window with more changes restarts from the last timestamp seen, without skipping or repeating records.
- Access tokens last 30 minutes. A long first sync would have failed halfway, and failed the same way on every retry. A 401 now mints a new token and retries once.
Deletes come from HubSpot's archived list, which keeps deleted records for 90 days. GDPR deletes, or records archived longer ago, need reconcile_deletes=True, which compares a full id listing.
Checking my own work
Two bugs I found while preparing the review fixes, before anyone else did:
-
An empty response could forget the whole CRM. A
200with an empty body was read as{}. Withreconcile_deletes=True, an empty id listing looked like "no records left", and every stored record would have been marked deleted. Any200without a JSON object now stops the run before anything is forgotten, tested through the real HTTP client. - My forget test proved too little. It stayed green even with the hard-delete marker switched off. It now checks that the deleted note's and contact's records are really gone from cognee, and that an untouched note keeps its id.
And from the live runs: the first read titled notes by id (Note 405507740407), since HubSpot notes have no subject. They now use the start of their text.
Privacy
Contacts have about 90 properties by default, including date of birth and gender. The connector reads an allowlist of properties, and the sensitive ones are never requested unless you ask for them with extra_properties.
What I learned
In a CRM, a delete is never just one record. Arjun's contact was one row, but his name was on the company's document too, and HubSpot stopped listing the link the moment he was deleted, so there was nothing left to tell the company's document to change. Relationships are where memory goes stale. Now, for every connector, I ask: when this record changes or disappears, which other documents mention it?
The setup steps are in the README in the pull request, and examples/example.py syncs your CRM and asks it a question.




Top comments (0)