Full source, exactly as shipped. The download above is byte-identical.
call_transcript_notes.jsJavaScript
/**
* CALL TRANSCRIPT NOTES → CRM
* ============================================================
* Six Zapier "Run JavaScript" steps that turn a finished call
* recording into a clean, attendee-tagged note posted back to
* the CRM. Each block below is a standalone step — drop each
* into its own Zapier Code step in order.
*
* Pipeline:
* 1. Filter workspace participants (block A)
* 2. Fetch call recording web URL (block B)
* 3. Pull + paginate full transcript (block C)
* 4. Parse a single transcript page (block D) // alt to C
* 5. Resolve participants → name + LinkedIn,
* sort externals first, dedupe, render HTML (block E)
*
* Swap "@yourcompany.com" + Attio auth for any other CRM.
* ============================================================
*/
// ──────────────────────────────────────────────────────────────
// A. FILTER PARTICIPANTS TO WORKSPACE DOMAIN
// Inputs: participants (comma-separated emails)
// Output: { workspaceEmails } — only your team's addresses
// Used to figure out who from your side was on the call.
// ──────────────────────────────────────────────────────────────
async function filterWorkspaceParticipants(inputData) {
const domain = "@yourcompany.com";
const emails = inputData.participants
.split(",")
.map((e) => e.trim());
const workspace = emails.filter((e) => e.endsWith(domain));
return { workspaceEmails: workspace.join(",") };
}
// ──────────────────────────────────────────────────────────────
// B. FETCH CALL RECORDING WEB URL
// Inputs: meetingId, callRecordingId, apiKey
// Output: { webUrl } — the shareable recording link
// Pulled separately so the note can link to the recording.
// ──────────────────────────────────────────────────────────────
async function getCallRecordingWebUrl(inputData) {
const { meetingId, callRecordingId, apiKey } = inputData;
const url = `https://api.attio.com/v2/meetings/${meetingId}/call_recordings/${callRecordingId}`;
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
const json = await res.json();
return { webUrl: json?.data?.web_url || null };
}
// ──────────────────────────────────────────────────────────────
// C. PULL FULL TRANSCRIPT (all pages, concatenated, HTML-ready)
// Inputs: meetingId, callRecordingId, apiKey
// Output: { fullTranscript }
// Paginates the transcript endpoint until next_cursor is null,
// then inserts <br> before every [HH:MM:SS] speaker turn so
// the note renders cleanly in an HTML email or CRM note.
// ──────────────────────────────────────────────────────────────
async function getFullTranscript(inputData) {
const { meetingId, callRecordingId, apiKey } = inputData;
async function getAllTranscripts() {
const transcripts = [];
let cursor = null;
while (true) {
let url = `https://api.attio.com/v2/meetings/${meetingId}/call_recordings/${callRecordingId}/transcript`;
if (cursor) url += `?cursor=${encodeURIComponent(cursor)}`;
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
const json = await res.json();
if (json?.data?.raw_transcript) {
transcripts.push(json.data.raw_transcript.trim());
}
if (!json?.pagination?.next_cursor) break;
cursor = json.pagination.next_cursor;
}
return transcripts;
}
const all = await getAllTranscripts();
let combined = all.join(" ");
// line break before every timestamped turn
combined = combined.replace(/\[\d{2}:\d{2}:\d{2}\]/g, (m) => `\n${m}`);
// convert to <br> for HTML emails / CRM notes
combined = combined.replace(/\n/g, "<br>");
return { fullTranscript: combined };
}
// ──────────────────────────────────────────────────────────────
// D. PARSE A SINGLE TRANSCRIPT PAGE (alternative to block C)
// Inputs: data (JSON or stringified JSON from a prior step)
// Output: { rawTranscript, cursor }
// Use this if you'd rather paginate via a Zapier loop step
// instead of doing it all inline in block C.
// ──────────────────────────────────────────────────────────────
function parseTranscriptPage(inputData) {
let parsed;
try {
parsed =
typeof inputData.data === "string"
? JSON.parse(inputData.data)
: inputData.data;
} catch {
parsed = {};
}
const nextCursor = parsed?.pagination?.next_cursor || null;
return {
rawTranscript: parsed?.data?.raw_transcript || "",
cursor: nextCursor ? [encodeURIComponent(nextCursor)] : [],
};
}
// ──────────────────────────────────────────────────────────────
// E. RESOLVE PARTICIPANTS → NAME + LINKEDIN, RENDER HTML BLOCK
// Inputs: attio_token, participants (csv), show_historic ("true"/"false")
// Output: { html_output }
// For each email: look up the CRM Person record, grab full name,
// fetch LinkedIn URL only for externals (skip teammates), sort
// externals first, dedupe by name, and render an HTML block ready
// to drop into the call note.
// ──────────────────────────────────────────────────────────────
async function buildParticipantsHtml(inputData) {
const TOKEN = inputData.attio_token;
const PARTICIPANTS = inputData.participants;
const SHOW_HISTORIC = inputData.show_historic === "true";
const ATTIO_BASE = "https://api.attio.com/v2";
const LINKEDIN_ATTRIBUTE = "linkedin";
const INTERNAL_DOMAIN = "@yourcompany.com";
async function getRecordByEmail(email) {
const body = {
filter: {
email_addresses: { email_address: { $eq: email } },
},
limit: 1,
};
const resp = await fetch(`${ATTIO_BASE}/objects/people/records/query`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await resp.json();
if (!data.data || data.data.length === 0) return null;
return data.data[0];
}
async function getLinkedIn(recordId) {
const url = `${ATTIO_BASE}/objects/people/records/${recordId}/attributes/${LINKEDIN_ATTRIBUTE}/values?show_historic=${SHOW_HISTORIC}`;
const resp = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
});
const attrData = await resp.json();
if (attrData.data && attrData.data.length > 0) {
const urlValue = attrData.data.find(
(d) => d.value && d.value.includes("linkedin.com"),
);
return urlValue ? urlValue.value : null;
}
return null;
}
function normalizeName(name) {
return (name || "").trim().replace(/\s+/g, " ").toLowerCase();
}
const emails = PARTICIPANTS.split(",").map((e) => e.trim()).filter(Boolean);
const results = await Promise.all(
emails.map(async (email) => {
const isInternal = email.toLowerCase().endsWith(INTERNAL_DOMAIN);
const rec = await getRecordByEmail(email);
if (!rec) return { name: "Unknown", email, linkedin: null, isInternal };
let fullName = null;
if (rec.values.name && rec.values.name[0]) {
fullName =
rec.values.name[0].full_name ||
`${rec.values.name[0].first_name || ""} ${rec.values.name[0].last_name || ""}`.trim();
}
let primaryEmail = email;
if (rec.values.email_addresses && rec.values.email_addresses.length > 0) {
primaryEmail = rec.values.email_addresses[0].email_address;
}
// only fetch LinkedIn for externals
const linkedin = isInternal ? null : await getLinkedIn(rec.id.record_id);
return {
name: fullName || "Unknown",
email: primaryEmail,
linkedin,
isInternal,
};
}),
);
// externals first
const sorted = results.sort((a, b) => {
if (a.isInternal === b.isInternal) return 0;
return a.isInternal ? 1 : -1;
});
// dedupe by normalized name (keep first occurrence)
const seen = new Set();
const deduped = sorted.filter((r) => {
const key = normalizeName(r.name);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const html = deduped
.map((r) => {
if (r.isInternal) return `${r.name}`;
const parts = [r.email];
if (r.linkedin) parts.push(r.linkedin);
return `${r.name} (${parts.join(", ")})`;
})
.join("<br>");
return { html_output: html };
}
// ──────────────────────────────────────────────────────────────
// In Zapier, each block above is its own "Run JavaScript" step.
// The body of that step is just: return blockName(inputData);
// e.g. return getFullTranscript(inputData);
// ──────────────────────────────────────────────────────────────