Attach business fields
Recipe: add customer, account or order fields to conversations as extra columns, with key cleanup, conflict checks and a match rate.
When you need it
Section titled “When you need it”You want to slice what customers said by what you know about them: plan tier, region, lifetime orders, deal stage. There are two ways in:
- Extra columns on the conversation file, best for a few fields that describe the customer at the time of the conversation. This recipe does that.
- A separate file under Structured data, best for data with its own grain (one row per order, per account per month). Upload it as described in Business data format; the agent joins on a shared id. Business data comes in by file upload only today (an API for it is on the roadmap). Use this recipe’s key cleanup and match-rate check on it too.
Orders, opportunities and other records are business data, not messages. Don’t turn an order line or a CRM record into a fake text row: it would be enriched as if a customer had written it.
Example input: conversations, already in the Dimension Labs format, with a customer_id column that isn’t clean (a leading space, lower case):
sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,customer_id7001,cus_51,true,The grinder makes a clicking noise.,1772615700000,7001, CUS-00517001,cus_51,false,We'll send a replacement today.,1772616660000,7001, CUS-00517002,cus_77,true,Do you ship to Canada?,1772726400000,7002,cus-00777003,cus_90,true,Cancel my subscription please.,1772812800000,7003,CUS-0090A customer list from the CRM. CUS-0077 appears twice with different tiers, and CUS-0090 is missing:
customer_id,tier,region,updated_atCUS-0051,Gold,West,2026-01-10CUS-0077,Silver,West,2025-11-02CUS-0077,Gold,West,2026-02-20CUS-0012,Bronze,South,2025-09-30Output
Section titled “Output”sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,customer_id,tier,region7001,cus_51,true,The grinder makes a clicking noise.,1772615700000,7001,CUS-0051,Gold,West7001,cus_51,false,We'll send a replacement today.,1772616660000,7001,CUS-0051,Gold,West7002,cus_77,true,Do you ship to Canada?,1772726400000,7002,CUS-0077,Gold,West7003,cus_90,true,Cancel my subscription please.,1772812800000,7003,CUS-0090,,Conversations matched: 2 of 3Unmatched keys: CUS-0090Keys with conflicting values (most recent kept): CUS-0077- Make the keys look the same on both sides. Trim spaces, and agree on case. Leading zeros, case and stray spaces all break a join. Only change case if the ids are case-insensitive in the source system.
- Keep the cleaned key in the file. The agent can then join the same conversations to other business tables on
customer_idlater. - One record per key. When a key appears twice with different values, decide which wins (here, the most recent
updated_at) and list the conflicts so someone can fix the source. - Measure the match rate before you upload. A low rate nearly always means the ids differ in format. The file checker’s join test does the same check in your browser.
- Fields are constant within a conversation. Every message in a session gets the same values.
- Only bring what you’ll use, and no personal data. Emails, phone numbers and addresses rarely help an analysis, and must never be the join key. Join on an internal id such as
customer_id; if the only shared key is an email, hash it the same way on both sides and join on the hash. See PII redaction.
Script
Section titled “Script”Node 18 or later, with npm install papaparse.
// Attach customer fields to every message of a conversation as extra columns, and report the match rate.// Usage: node attach-fields.mjs conversations.csv customers.csv conversations-with-fields.csvimport fs from 'node:fs';import Papa from 'papaparse';
const [convFile, customerFile, output] = process.argv.slice(2);const KEY = 'customer_id';const FIELDS = ['tier', 'region'];// Make both sides look the same. Only uppercase if your ids are case-insensitive in the source system.const normalize = (k) => String(k ?? '').trim().toUpperCase();
const read = (f) => Papa.parse(fs.readFileSync(f, 'utf8'), { header: true, skipEmptyLines: 'greedy' }).data;const conversations = read(convFile);const customers = read(customerFile);
// One record per key. When a key appears twice with different values, keep the most recent and say so.const byKey = new Map();const conflicts = [];for (const c of customers) { const k = normalize(c[KEY]); const prev = byKey.get(k); if (prev && FIELDS.some((f) => prev[f] !== c[f])) conflicts.push(k); if (!prev || c.updated_at > prev.updated_at) byKey.set(k, c);}
const sessions = new Set();const matched = new Set();const out = conversations.map((m) => { const k = normalize(m[KEY]); sessions.add(m.sessionId); const c = byKey.get(k); if (c) matched.add(m.sessionId); return { ...m, [KEY]: k, ...Object.fromEntries(FIELDS.map((f) => [f, c ? c[f] : ''])) };});
fs.writeFileSync(output, Papa.unparse(out, { newline: '\n' }) + '\n');console.log(`Conversations matched: ${matched.size} of ${sessions.size}`);console.log(`Unmatched keys: ${[...new Set(out.filter((m) => !matched.has(m.sessionId)).map((m) => m[KEY]))].join(', ') || 'none'}`);console.log(`Keys with conflicting values (most recent kept): ${[...new Set(conflicts)].join(', ') || 'none'}`);node attach-fields.mjs conversations.csv customers.csv conversations-with-fields.csvVariations
Section titled “Variations”- Fields that change over time (the plan a customer was on when they wrote): pick the record whose date range contains the conversation’s
dimensionlabs_timestamp, rather than the latest one. - Totals from an orders file (orders in the last 90 days, lifetime value): compute them per key before attaching, and say in the column name what they cover, for example
orders_90d_before_chat. - Many fields, or fields with their own grain: upload the business file under Structured data instead and ask the agent to join, for example “Join my conversations to the customers table on customer_id.”