A relationship between two records can be told to the system, inferred deterministically from extracted properties, or surfaced by an LLM reading free-text memories. Three independent channels, three different cost profiles, opt-in per write. The same three apply whether the write target is a record or a document. The graph is not a separate system; it is an opt-in fan-out on the same save call.
CRMs are graphs nobody wrote down
Every CRM is already a graph. Jane reports to Marcus. Marcus is the CFO at Acme. Acme is a customer of three different vendors. Jane previously worked at Globex. Globex is a partner of Initech. The relationships are everywhere. They are usually not stored anywhere queryable.
Production agents need the graph. "Who else at Acme should I include in this outreach?" needs an aliasExpansion. "Who is the actual decision-maker?" needs a reports_to chain. "Are any of our existing customers mentioning this new partner?" needs mentions edges. Without a graph, the agent does the joins client-side, or worse, guesses.
The instinct, when teams first need this, is to add a graph database. Neo4j, JanusGraph, Amazon Neptune, or a homegrown adjacency table. The problem is not the technology; any of those work. The problem is that adding a separate graph store means a second write path: every memorize call now also has to figure out which edges to write, where to write them, how to keep them consistent with the property store, and what to do when the edge points at a record that does not exist yet.
The pattern that worked, for us, is to keep the graph in the same store as records and to expose three independent channels for writing edges, all opt-in per save call. The graph is not a separate system; it is a fan-out on the existing write path.
This article is about those three channels (what each one does, when each one is right, what each one costs) and the symmetry that makes the same pattern apply whether you are saving a record or a document.
Channel A: declared edges
The first channel is for the case where the caller already knows the edge. A CRM sync pulls a contact with a manager_id. An enrichment vendor returns a contact and explicitly says works_at: company_xyz. A classification step in your own pipeline decides this email mentioned a competitor.
In these cases, the relationship is data you already have. You do not need the system to infer it. You declare it.
{
"content": "...",
"email": "jane@acme.com",
"relations": [
{
"relationType": "works_at",
"toIdentity": { "kind": "websiteUrl", "value": "acme.com" },
"toEntityType": "company"
},
{
"relationType": "reports_to",
"toRecordId": "REC#MARCUS",
"confidence": 1.0
}
]
}Behavior:
- The edge is written synchronously as part of the memorize call. If memorize succeeds, the edge is immediately queryable.
toRecordIdpoints at an existing record directly.toIdentitylooks up an existing record by that key. If the record does not exist yet, a stub record gets created so the edge has a real target. The stub gets promoted to a full record the moment someone memorizes against it.relationTypemust be in the org's relation registry. Unknown types are dropped.- Cost: free. Bundled in the memorize charge.
This is the right channel for any data that arrived already-labeled. CRM syncs almost always have it. Enrichment vendors almost always have it. Manual labeling steps in your pipeline always have it.
It is the wrong channel for content where the relationships are present but not explicitly labeled, a transcript that says "she chose us over Stellar AI" carries an edge, but nobody set relations: [{ relationType: "competed_against", ... }] on the API call. That is what the next two channels are for.
Channel B: property-inferred edges
The second channel runs after extraction completes. Memorize has already pulled typed properties from the content, job_title, company_name, manager_email, whatever the schema defines. The property-inference engine inspects those extracted values and writes the edges they imply.
{
"content": "Brianna Rivera works at Acme Robotics as Head of Product.",
"email": "brianna@acme.com",
"collectionGraph": true
}Extraction pulls company_name: "Acme Robotics", job_title: "Head of Product". The property-inference engine sees company_name on a contact and writes a works_at edge to a company record matching "Acme Robotics" (creating a stub if one does not exist).
Behavior:
- Runs asynchronously in a background worker, typically 2-10 seconds after memorize returns.
- Deterministic. No LLM in this channel. Just rules over extracted properties. The same input produces the same edges every time.
- Edges are written with
source: 'property_inference'. - Idempotent. Re-memorizing the same content produces the same edges, no duplicates.
- Cost: near-free. No LLM token spend; just a small amount of compute.
The rules are domain-specific. company_name on a contact yields works_at. manager_email yields reports_to. vendor_name yields uses_vendor. The org's schema and relation registry decide what gets inferred from what. Most production schemas have ten to thirty of these rules; together they catch most of the graph for free.
This is the right channel when the relationships are reliably present in extracted property values. It is the wrong channel for relationships that live only in free text ("she mentioned that her team is partnering with Stellar AI") because no property captures that sentence as a typed field.
Channel C: LLM-inferred edges from memories
The third channel runs against atomic memories, the open-set facts extracted from the content body. An LLM (Bedrock Nova Micro, cheap and fast) reads each memory and proposes edges grounded in entities the model can identify by strong identifier.
{
"content": "Carlos discussed a partnership with Priya Nair (priya@datafleet.ai). They might go deep on integration in Q3.",
"email": "carlos@example.com",
"smartGraph": true
}Extraction produces atomic memories like:
"Carlos discussed a partnership with Priya Nair (priya@datafleet.ai) on the June 2026 call."
The smartGraph worker reads each memory. It sees a strong identifier (the email priya@datafleet.ai) attached to a person and proposes a mentions or partners_with edge from Carlos's record to a (stubbed) contact with email priya@datafleet.ai.
Behavior:
- Runs asynchronously, same window as Channel B (2-10 seconds after memorize returns).
- The LLM is constrained to the org's relation registry. It does not invent edge types; it picks from what is allowed.
- Strong-identifier requirement. Mentions need an email, domain, phone, or other strong key to be written. Name-only mentions ("she met with Priya") are dropped, because resolving "Priya" without an identifier is the kind of bug that creates wrong edges silently.
- Edges are written with
source: 'llm_inference',confidence ≤ 0.95. - Cost: per LLM input token at your memorize tier. Nova Micro is cheap (roughly a tenth of a cent per call at typical sizes) but it is not free.
- Idempotent at storage. Re-running on the same memories does not produce duplicates.
This is the right channel for the long tail. The competitive mentions in call transcripts. The partner namings in deal reviews. The hiring signals that imply org connections. The text-only relationships that no property schema anticipates.
It is the wrong channel for relationships you already know (use Channel A) or for relationships reliably extractable from properties (use Channel B). Spending LLM tokens on inference for edges that could be declared or rule-extracted is wasted budget.
The cost shape, side by side
The three channels form a clean cost-versus-confidence curve.
| Channel | Mechanism | Latency | Cost | When right | |---|---|---|---|---| | A declared | caller supplies | sync, write succeeds | free | edge data is in your input | | B property-inferred | rules over properties | async, seconds | near-free | edge is in typed values | | C LLM-inferred | Nova Micro reads memories | async, seconds | per-token | edge is only in free text |
They are independent. A single memorize call can use any subset of the three by setting relations, collectionGraph: true, and smartGraph: true in whatever combination fits the content.
{
"content": "...",
"email": "jane@acme.com",
"relations": [ { "relationType": "works_at", "toIdentity": {...} } ],
"collectionGraph": true,
"smartGraph": true
}In that call, Channel A writes the works_at edge synchronously. Channel B runs after extraction and writes any additional edges implied by typed properties. Channel C runs after extraction and surfaces any text-only mentions that have strong identifiers. Three fan-outs, three different mechanisms, one save call.
The same pattern, for documents
The architectural point that makes the three-channel model worth writing about is that it generalizes. context/save (the document write path) exposes the same three channels under different field names.
Layer A, docLinks. When the document save includes recordIds: [...], the system writes typed doc-to-record links automatically. No LLM, no inference, just typed edges from the document to the records the caller declared it relates to.
Layer B, declared relations. When the document save includes a relations[] array (with the same shape as Channel A on memorize), record-to-record edges get written from the doc save call. The doc itself becomes the evidence for the edge.
Layer C, LLM extraction. When the document save includes extractEntities: true (the doc-side analog of smartGraph), an LLM reads the document body, extracts entities and proposes relationships against strong identifiers, and writes edges with the document as evidence.
// Document save with all three layers
{
"name": "acme-deal-review-2026q2",
"type": "brief",
"value": "# Acme Q2 Deal Review\n\n## Stakeholders\n- Jane (CTO, jane@acme.com)\n- Marcus (CFO, marcus@acme.com)\n...",
"recordIds": ["REC#JANE", "REC#MARCUS"], // Layer A
"relations": [{ "relationType": "mentions", // Layer B
"toIdentity": {...} }],
"extractEntities": true // Layer C
}Edges discovered through any layer are queryable back to the source document via an evidence_document_ids column. When the document is deleted, edges where the deleted doc was the sole evidence get garbage-collected.
The symmetry is the point. The same mental model applies whether the write is a record or a document. Same three opt-in channels, same cost profile, same idempotency guarantees. An agent or developer who learns the channel pattern once knows how to write the graph against both kinds of writes.
Stub records and forward compatibility
One detail that makes the channel pattern actually work in production is the stub-record behavior. When any channel writes an edge whose target does not exist yet, the system creates a stub.
A stub record is a minimal record with an identity (email, websiteUrl, phone) but no extracted content yet. It exists so the edge has a real, addressable target.
The first time someone runs a memorize call against that identity, the stub gets promoted in place. The recordId stays the same. The properties get filled in. The memories get attached. The edges that were already pointing at the stub now point at a full record, with no migration needed.
This matters because it makes the graph forward-compatible. You can write an edge today against an identity you have not memorized yet, and tomorrow when the data arrives, the edge is automatically connected to a richer record. The agent that was holding the relationship "Carlos partners with priya@datafleet.ai" does not have to wait for Priya's CRM record to materialize before it can write the edge.
For document saves, the same applies. A document mentioning priya@datafleet.ai writes an edge through Layer C. Priya's stub gets created. The doc's evidence_document_ids correctly attributes the edge to this document. When Priya's actual CRM data syncs in next week, the stub gets promoted, and her record is already wired into the document graph.
The principle
Edges are knowledge with a cost shape. Declared edges are free because you already had them. Property-inferred edges are near-free because rules are cheap. LLM-inferred edges cost what an LLM call costs because that is what you are paying for.
Splitting the three lets each channel earn its keep. You do not pay LLM rates for an edge that could have been declared, and you do not write a custom rule engine for a relationship that lives only in free text.
The graph that emerges is the union of all three channels across thousands of save calls. The customer chose, per call, how much to pay for what. The system never invented an edge type that the customer did not register. The stubs that get created are auditable, deletable, and forward-compatible. The same three channels apply whether the content was a transcript or a markdown playbook.
If you are building agent memory and your current pattern is "Neo4j on the side, manually wired," consider this instead. Make graph writes a fan-out on the same save call your customer is already making. Give them three knobs, default them all off, and let them pick which channel earns the edge for their content. The graph stops being a separate system and starts being a property of the saves they were already doing.
Companion pieces: Three Modalities, One Save Surface covers the modality choices that feed into these channels. One Call, Four Sources covers the retrieval side where graph appears as one of the four returned sources.