The scenario
Arcline Mobile — the telco from our Calculated Insights build — put an Agentforce agent on live support calls. The agent needs the caller's full picture the moment the call connects: current plan, open cases, the last five interactions, the churn-risk score, and this month's usage against quota.
The first implementation queried Data 360 with ANSI SQL per call — five DMOs, three joins, one CI. It worked in the demo. In production it added two to four seconds before the agent's first useful turn, and every call burned query credits recomputing joins whose answers hadn't changed since the previous call. Multiply by 40,000 calls a day.
We introduced data graphs briefly in the Agentforce grounding build. This post is the deep version: how to design one properly, what the materialized document actually looks like, and how to read it from REST and Apex at conversation speed.
The core trade: compute once, read many
A data graph inverts the query model. Instead of assembling the profile at read time, Data 360 assembles it at write time: it denormalizes a primary DMO and its related objects into a single materialized JSON document, and keeps that document updated as the underlying data changes. Reads become key lookups — no joins, no query planning, near-real-time responses.
The trade-offs are the classic materialized-view ones, and they discipline your design:
- You pay storage and refresh compute for every profile, including the millions nobody will call about today.
- The document answers exactly the shape you designed — new questions mean changing the graph, not the query.
- Freshness is "near real time" by default; for moments where that's not enough, the lookup API has a live-read escape hatch (below).
For a support call, that trade is exactly right: the questions are known in advance, and latency is the whole game.
Designing Arcline's graph
In Data 360 → Data Graphs → New, design is three decisions:
- Primary object: Unified Individual — the graph produces one document per unified customer.
- Related objects:
Subscription__dlm(plan, contract end), the Case DMO filtered to open cases,Interaction__dlmlimited to recent records, and the churn-risk CI object. Related objects hang off the primary via the model's relationships. - Fields: ruthlessly few. Plan name, contract end date, case subjects and statuses, interaction channel and timestamp, one churn score. Every field you add is paid for in every document, every refresh.
The materialized document the agent's action receives looks like this (trimmed):
{
"ssot__Id__c": "0aXxx0000004GHi",
"ssot__FirstName__c": "Priya",
"Subscription__dlm": [
{ "plan_name__c": "Unlimited Plus",
"contract_end__c": "2027-02-28" }
],
"Case__dlm": [
{ "Subject": "Billing dispute - roaming",
"Status": "Open" }
],
"Churn_Risk_Insight__cio": [
{ "churn_score__c": 74 }
]
}
Reading it at runtime: the lookup API
Data graphs have a dedicated lookup endpoint — a key-value read, not a query. Fetch a customer's document by any participating DMO's key:
GET /api/v1/dataGraph/Arcline_Customer360
?lookupKeys=[UnifiedIndividual__dlm.ssot__Id__c=0aXxx0000004GHi]
Three details that matter in production:
lookupKeystakes DMO-qualified keys — you can look up by the unified ID, or by a source key such as a phone number mapped into the graph, which is exactly what a voice channel has at call start.&live=trueforces a live read of the underlying data instead of the materialized document — fresher, slower, costlier. Arcline uses it in exactly one place: immediately after a mid-call plan change, where reading a stale document back to the customer would be worse than a pause.- The response envelope is
{ "data": [ … ], "done": true }— your parser should treatdataas an array even when you expect one profile.
Calling it from Apex
Arcline's agent action wraps the lookup in a service class behind a Named Credential (Data360_API), so auth stays out of code:
public with sharing class CustomerGraphService {
public class GraphResult {
public String planName;
public Integer churnScore;
public List<String> openCaseSubjects = new List<String>();
}
public static GraphResult fetch(String unifiedId) {
HttpRequest req = new HttpRequest();
req.setMethod('GET');
req.setEndpoint('callout:Data360_API/api/v1/dataGraph/Arcline_Customer360' +
'?lookupKeys=[UnifiedIndividual__dlm.ssot__Id__c=' +
EncodingUtil.urlEncode(unifiedId, 'UTF-8') + ']');
req.setTimeout(5000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('Graph lookup failed: ' + res.getStatus());
}
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> data = (List<Object>) body.get('data');
GraphResult result = new GraphResult();
if (data == null || data.isEmpty()) { return result; }
Map<String, Object> profile = (Map<String, Object>) data[0];
for (Object sub : listOf(profile.get('Subscription__dlm'))) {
result.planName = String.valueOf(
((Map<String, Object>) sub).get('plan_name__c'));
}
for (Object c : listOf(profile.get('Case__dlm'))) {
result.openCaseSubjects.add(String.valueOf(
((Map<String, Object>) c).get('Subject')));
}
for (Object ci : listOf(profile.get('Churn_Risk_Insight__cio'))) {
Object score = ((Map<String, Object>) ci).get('churn_score__c');
result.churnScore = score == null
? null : Integer.valueOf(String.valueOf(score));
}
return result;
}
private static List<Object> listOf(Object node) {
return node == null ? new List<Object>() : (List<Object>) node;
}
}
Wrap fetch in an @InvocableMethod using the action pattern from the grounding build and the agent gets its whole opening context in one sub-second call — one document read instead of the four-join SQL that started this story.
Freshness, sizing and the real-time layer
- Refresh behavior: the graph updates as underlying DMOs change — near real time for ordinary ingestion. What it can't outrun is your pipeline: if usage data lands nightly, the graph is nightly-fresh no matter how fast the lookup is.
- Sub-second end to end: when the underlying events also need to arrive in milliseconds — a web session feeding a live conversation — that's the real-time layer with real-time data graphs, covered in the streaming post of this series.
- Sizing: watch related-object counts and depth. A graph is a document store in disguise; treat "how big is one document" as a design review question, and re-check after adding any child list.
- One graph per consumer shape: Arcline runs a slim service graph (this build) and a separate marketing graph with engagement summaries — two small documents beat one bloated universal profile.
What you learned
- Data graphs move join cost from read time to write time — the right shape for known questions at conversation latency.
- Design is primary object + few related objects + ruthless field selection; aggregates live in CIs, capped lists in the graph.
- The lookup API reads by DMO-qualified keys;
live=trueis a deliberate, costed choice for must-be-fresh moments. - A Named Credential + JSON parse is all the Apex you need to put a full customer 360 behind an agent action.
- Graph freshness is bounded by ingestion freshness — sub-second scenarios need the real-time layer.
Next in the advanced series: advanced identity resolution — the graph is only as good as the unified profile underneath it. The full track lives on the Data 360 page.
Sources: Salesforce Help — Data Graphs · Data 360 Query Guide — Get Data Graph Data with Lookup Keys · Data 360 Query Guide — Query Data Graph Data