Code & Snippets

Call Transcript → CRM Note (Zapier glue).

Five Zapier 'Run JavaScript' steps that turn a finished call recording into a clean, attendee-tagged note posted back to the CRM — transcript pagination, recording URL, and an externals-first participant block.

Pure Operations
JavaScript · 278 lines
C.1

Download

Grab the source file. Drop it into your service, set the required environment variables, and deploy.

C.2

How it works

The gist

Five small JavaScript steps that, chained inside one Zap, turn a finished call recording into a clean, attendee-tagged note posted back to the CRM. Each block is a standalone Zapier "Run JavaScript" step — the snippet file groups them together so you can read the whole pipeline in one place and lift the pieces you actually need.

The blocks

  1. Filter participants to workspace domain — split the comma-separated attendee list, keep only your team's addresses. Used to figure out who from your side was on the call.
  2. Fetch call recording web URL — one GET to the recording endpoint, returns just the shareable link so the note can deep-link to the recording.
  3. Pull the full transcript — paginates the transcript endpoint until next_cursor is null, concatenates the pages, inserts a <br> before every [HH:MM:SS] speaker turn so the note renders cleanly in an HTML email or CRM note.
  4. Parse a single transcript page — alternative to block 3 if you'd rather paginate via a Zapier loop step. Same parsing, one page at a time, returns the cursor for the next iteration.
  5. Resolve participants → name + LinkedIn, render HTML block — for each email, look up the CRM Person record, grab the full name, fetch LinkedIn only for externals (skip teammates), sort externals first, dedupe by name, render a single HTML block ready to drop into the call note.

Swapping CRMs

Built against Attio's /v2 API but the structure is generic. To run against another CRM, swap:

  • The base URL and endpoint paths
  • The Authorization header for whatever auth scheme the CRM uses
  • The field paths inside the person lookup (values.name, values.email_addresses) to that CRM's record shape
  • @yourcompany.com for your actual workspace domain

The pagination loop, the externals-first dedupe, and the HTML render shape stay the same.

Why this exists

The handful of small JS steps in this Zap are the boring glue that turns a raw recording into something a human actually wants to read in the CRM — recording link, clean transcript, and a properly formatted attendee block with the externals on top.

C.3

Used in this automation

This snippet powers the end-to-end automation below. Open it for the full tool chain, prompts, and job-to-be-done context.

C.4

Source

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);
// ──────────────────────────────────────────────────────────────