Claude for Agents · Build

Closing Protocol — the SOP that runs itself

Fill in a closing, check one box: the review request is drafted, every teammate gets their tasks, and nothing gets dropped.

  • Setup ~15 min
  • Google Sheets + Gmail
  • One Google Sheet
  • Runs on a checkbox
Before you start

Fifteen minutes, a blank Google Sheet, and no code to write.

You paste the script once, and everything you’ll ever change afterwards lives on a tab: your name, your people, your review links, your steps.

You need a Google account with Gmail and Sheets — free and Workspace both work — and there’s no cost, because it runs on tools you already have. You do not need to know how to code.

There’s no download on this page, by design. The resource is the code itself, in Step 2, with a copy button. Paste it into your own Apps Script editor and it’s yours.

Be the first to hear about the next one

There’s nothing to download here — the resource is on the page. But new builds, skills and agents ship most weeks, and the Take It and Run newsletter is where they land first: one practical move a week, and you’ll hear about a new release the moment it’s out.

Unsubscribe any time. We never sell or share your address.

The install guide

A closing checklist that runs itself.

This system turns your after-closing checklist into a workflow that actually runs. Fill in a closing, check one box, and it drafts the review request, tells the right person to update the CRM, adds the client to your card list, and tracks every task with reminders until it’s done.

The closing protocol is only the example

The real skill is learning to turn any dusty SOP into a living, self-running system. Once you’ve built this one, every page of that binder is something you can build.

Step 1. What it does

When you check the Open Protocol box on a closing, the system:

  • Drafts the review-request email to your client — saved as a draft, you send it.
  • Emails each teammate the tasks assigned to them, with due dates.
  • Adds the client to your Address Labels file automatically, a separate sheet for mail-merging cards.
  • Builds a dated, owned checklist on the Tasks tab — one set per closing, so two closings the same day never collide.
  • Sends a weekly reminder to anyone with still-open tasks, overdue items flagged.
  • Emails you when every task on a closing is done or intentionally skipped.

Step 2. Install it

About five minutes.

  1. Create a new, blank Google Sheet.
  2. Open Extensions → Apps Script.
  3. Delete the function myFunction() {} starter that’s already there, so the editor is empty.
  4. Copy the whole script below, paste it in, and click Save.
  5. In the function dropdown at the top, choose setUp and press Run. Approve the permissions when Google asks.

The permissions screen — this is normal. Google will warn you that the app “isn’t verified”. That’s expected: it’s your script, running in your account, that you just pasted in. Click Advanced, then “Go to (your project) — unsafe”. The word “unsafe” only means Google hasn’t reviewed it, because it’s yours. Approve the access. This is the one step people quit on; don’t.

Closing-Protocol.gs
/**********************************************************************
 *  CLOSING PROTOCOL  -  a closing SOP that runs itself
 *
 *  Fill in a closing, check ONE box. The system reads your SOP (the
 *  "Template" tab) and stamps out a dated, owned checklist on "Tasks" -
 *  one set per closing, so two closings the same day never collide.
 *  It drafts the review email, writes clients to your Address Labels
 *  file, emails each teammate their tasks, nags weekly on anything
 *  still open, and pings you when a closing is fully wrapped.
 *
 *  ALL OF YOUR SETUP HAPPENS ON THE "START HERE" TAB.
 *
 *  FIRST-TIME SETUP (do it THIS way, once):
 *    1. Extensions -> Apps Script. Paste this whole file in. Save.
 *    2. In the Apps Script EDITOR, pick "setUp" in the function
 *       dropdown and press Run. Approve the permissions.
 *       >> Run it from the editor the first time. That's what triggers
 *          the permission prompt. After that, use the sheet menu.
 *    3. Back on the sheet: fill in the "Start Here" tab.
 *    4. Menu: Closing Protocol -> Seed sample test data.
 *    5. Menu: Reset wipes test data (your setup stays).
 **********************************************************************/

var TAB = { START:'Start Here', CLOSINGS:'Closings', TASKS:'Tasks',
            TEMPLATE:'Template', REVIEW:'Review Email', LISTS:'Lists', BYPROP:'By Property' };

// Closings columns (A=1...). ID + WRAPPED are hidden bookkeeping.
var C = { ID:1, NAME:2, GREET:3, ADDRESS:4, EMAIL:5, DATE:6, AGENT:7, RUN:8, STATUS:9, WRAPPED:10 };
// Tasks columns.
var T = { CID:1, ADDRESS:2, ITEM:3, TYPE:4, OWNER:5, ASSIGNEE:6, DUE:7, STATUS:8, UPDATED:9 };
// By Property columns (G holds the source Closing ID, hidden).
var BP = { ITEM:1, OWNER:2, ASSIGNEE:3, DUE:4, STATUS:5, UPDATED:6, CID:7 };

// Where things live on Start Here.
var SH = { PEOPLE_FIRST:9, PEOPLE_LAST:58, LINKS_FIRST:62, LINKS_LAST:71 };

var AUTO = '⚡ Automatic (no one)';
var SALES = 'Sales Agent (this closing)';

var CLR = { start:'#B45309', closings:'#16A34A', tasks:'#2563EB',
            template:'#7C3AED', review:'#DC2626',
            edit:'#FFF2CC', done:'#D9EAD3', skip:'#EFEFEF', open:'#FFF2CC',
            auto:'#E8F0FE', grey:'#666666' };

// ====================  MENU  ====================
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('🏠 Closing Protocol')
    .addItem('Set up / repair this sheet', 'setUp')
    .addSeparator()
    .addItem('Seed sample test data', 'seedTestData')
    .addItem('🎬 Demo: generate every email (for recording)', 'demoGenerateAllEmails')
    .addItem('Run protocol on the closing I selected', 'runSelectedClosing')
    .addItem('Send weekly reminder now (test)', 'weeklyOutstanding')
    .addSeparator()
    .addItem('Reset (clear Closings + Tasks)', 'resetSystem')
    .addToUi();
}

// ====================  ONE-TIME SETUP  ====================
function setUp() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  // ---------- START HERE : everything you configure lives here ----------
  var st = getOrCreate_(ss, TAB.START);
  st.setColumnWidth(1, 300); st.setColumnWidth(2, 300); st.setColumnWidth(3, 30);
  st.setColumnWidth(4, 460);

  st.getRange('A1:D1').setBackground(CLR.start);
  st.getRange('A1').setValue('🏠  CLOSING PROTOCOL  —  set up everything on this tab')
    .setFontSize(16).setFontWeight('bold').setFontColor('#ffffff');

  // Step 1 - your info
  st.getRange('A3').setValue('STEP 1  —  YOUR INFO').setFontWeight('bold').setFontColor(CLR.start);
  st.getRange('A4').setValue('Your name (signs your emails)');
  st.getRange('A5').setValue('Your email (gets the all-done notice)');
  st.getRange('B4:B5').setBackground(CLR.edit);
  st.getRange('D4').setValue('←  Type your name and email in the yellow cells. If you leave the email blank, notices go to whoever owns this sheet.')
    .setFontColor(CLR.grey).setWrap(true);

  // Step 2 - your people
  st.getRange('A7').setValue('STEP 2  —  YOUR PEOPLE').setFontWeight('bold').setFontColor(CLR.start);
  st.getRange('A8:B8').setValues([['Name','Email']]);
  styleHeader_(st, 'A8:B8', CLR.start);
  if (st.getRange('A9').getValue() === '') {
    st.getRange('A9:A10').setValues([['Team Leader'],['Admin']]);
  }
  st.getRange(SH.PEOPLE_FIRST, 1, SH.PEOPLE_LAST-SH.PEOPLE_FIRST+1, 2).setBackground(CLR.edit);
  st.getRange('D8').setValue(
    'Everyone who can be handed a task. Rename "Team Leader" and "Admin" to real people, then add a row for each teammate — including every sales agent on your team.\n\n' +
    'These names fill the Owner dropdown on the Template tab and the Sales Agent dropdown on the Closings tab.\n\n' +
    'Use each name only once.')
    .setFontColor(CLR.grey).setWrap(true).setVerticalAlignment('top');
  st.getRange('D8:D14').merge();

  // Step 3 - review links
  st.getRange('A60').setValue('STEP 3  —  YOUR REVIEW LINKS').setFontWeight('bold').setFontColor(CLR.review);
  st.getRange('A61:B61').setValues([['Platform','Review Link']]);
  styleHeader_(st, 'A61:B61', CLR.review);
  if (st.getRange('A62').getValue() === '') {
    st.getRange('A62:B63').setValues([
      ['Google','https://g.page/r/PASTE-YOUR-GOOGLE-LINK/review'],
      ['Zillow','https://www.zillow.com/profile/PASTE-YOUR-PROFILE']
    ]);
  }
  st.getRange(SH.LINKS_FIRST, 1, SH.LINKS_LAST-SH.LINKS_FIRST+1, 2).setBackground(CLR.edit);
  st.getRange(SH.LINKS_FIRST, 2, SH.LINKS_LAST-SH.LINKS_FIRST+1, 1).setFontColor(CLR.review).setFontWeight('bold');
  st.getRange('D61').setValue(
    '⚠️  Replace the PASTE... placeholders with your real review links, or they won’t appear in the email.\n\n' +
    'Add as many platforms as you like — Google, Zillow, Facebook, Yelp. Any row still saying PASTE is skipped.')
    .setFontColor(CLR.review).setWrap(true).setVerticalAlignment('top');
  st.getRange('D61:D66').merge();

  // Cheat sheet
  st.getRange('F3').setValue('THE TABS').setFontWeight('bold').setFontColor(CLR.start);
  st.getRange('F4').setValue('🟢 Closings — your daily driver. Add a closing, check the box.');
  st.getRange('F5').setValue('🔵 Tasks — the checklist that appears automatically.');
  st.getRange('F6').setValue('🟣 Template — your SOP: the steps, owners, due dates.');
  st.getRange('F7').setValue('🔴 Review Email — the wording of the review request.');
  st.getRange('F9').setValue('DON’T FORGET').setFontWeight('bold').setFontColor(CLR.start);
  st.getRange('F10').setValue('Share this sheet (top-right) with your team as Editors — otherwise they can’t check anything off.');
  st.setColumnWidth(6, 440);

  var labels = getLabelsFile_();
  st.getRange('F12').setValue('YOUR ADDRESS LABELS FILE').setFontWeight('bold').setFontColor(CLR.start);
  st.getRange('F13').setFormula('=HYPERLINK("' + labels.getUrl() + '","→ Open Address Labels file")');
  st.getRange('F14').setValue('Auto-updates on every closing. Mail-merge your cards from it.').setFontColor(CLR.grey);

  // ---------- Lists (hidden): the Owner dropdown options ----------
  var ls = getOrCreate_(ss, TAB.LISTS);
  ls.getRange('A1').setValue(AUTO);
  ls.getRange('A2').setValue(SALES);
  ls.getRange('A3').setFormula("=IFERROR(FILTER('Start Here'!A" + SH.PEOPLE_FIRST + ":A" + SH.PEOPLE_LAST + ",'Start Here'!A" + SH.PEOPLE_FIRST + ":A" + SH.PEOPLE_LAST + "<>\"\"),\"\")");
  ls.hideSheet();

  // ---------- Closings ----------
  var cl = getOrCreate_(ss, TAB.CLOSINGS);
  cl.getRange('A1:J1').setValues([[
    'Closing ID','Client Name','Greeting (optional)','Property Address',
    'Client Email(s)','Closing Date','Sales Agent','▶ Open Protocol','Protocol Status (auto)','Wrapped?'
  ]]);
  styleHeader_(cl, 'A1:J1', CLR.closings);
  cl.setFrozenRows(1);
  cl.getRange('H2:H1000').setDataValidation(
    SpreadsheetApp.newDataValidation().requireCheckbox().build());
  cl.getRange('G2:G1000').setDataValidation(
    SpreadsheetApp.newDataValidation()
      .requireValueInRange(st.getRange(SH.PEOPLE_FIRST, 1, SH.PEOPLE_LAST-SH.PEOPLE_FIRST+1, 1), true).build());
  cl.getRange('B1').setNote('However you’d write it: "Thomas & Sarah Jones" or "The Miller Family".');
  cl.getRange('C1').setNote('Optional. What goes after "Hi ___," in the review email. Blank = uses Client Name.');
  cl.getRange('E1').setNote('One email, or several separated by commas — e.g. a couple who both get asked for a review.');
  cl.getRange('G1').setNote('Who represented this deal. Any Template step owned by "Sales Agent (this closing)" goes to this person.');
  cl.setColumnWidth(2, 180); cl.setColumnWidth(4, 230); cl.setColumnWidth(5, 210);
  cl.setColumnWidth(7, 140); cl.setColumnWidth(9, 300);
  cl.hideColumns(C.ID); cl.hideColumns(C.WRAPPED);
  band_(cl, 'A2:I120');
  cl.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$J2="YES"')
      .setBackground(CLR.done).setRanges([cl.getRange('A2:I1000')]).build()
  ]);

  // ---------- Tasks ----------
  var tk = getOrCreate_(ss, TAB.TASKS);
  tk.getRange('A1:I1').setValues([[
    'Closing ID','Property Address','Item','Type','Owner',
    'Assigned To','Due Date','Status','Last Updated'
  ]]);
  styleHeader_(tk, 'A1:I1', CLR.tasks);
  tk.setFrozenRows(1);
  tk.getRange('H2:H2000').setDataValidation(
    SpreadsheetApp.newDataValidation().requireValueInList(['Open','Done','Skipped'], true).build());
  tk.setColumnWidth(3, 320); tk.setColumnWidth(6, 210); tk.setColumnWidth(9, 250);
  var trng = tk.getRange('A2:I2000');
  tk.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$H2="Open"').setBackground(CLR.open).setRanges([trng]).build(),
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$H2="Done"').setBackground(CLR.done).setRanges([trng]).build(),
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$H2="Skipped"').setBackground(CLR.skip).setFontColor('#9E9E9E').setRanges([trng]).build()
  ]);

  // ---------- By Property (see AND edit one closing's tasks) ----------
  var bp = getOrCreate_(ss, TAB.BYPROP);
  bp.getRange('A1').setValue('Show tasks for →').setFontWeight('bold').setFontColor(CLR.tasks);
  bp.getRange('B1').setBackground(CLR.edit).setDataValidation(
    SpreadsheetApp.newDataValidation().requireValueInRange(cl.getRange('D2:D1000'), true).build());
  bp.getRange('A2:F2').setValues([['Item','Owner','Assigned To','Due Date','Status','Last Updated']]);
  styleHeader_(bp, 'A2:F2', CLR.tasks);
  if (bp.getRange('A3').getValue() === '' || String(bp.getRange('A3').getFormula()).indexOf('QUERY') === 0) {
    bp.getRange('A3').clearContent();
    bp.getRange('A3').setValue('← Pick a property in cell B1 to see its tasks. You can mark Status right here.').setFontColor(CLR.grey);
  }
  bp.getRange('E3:E1000').setDataValidation(
    SpreadsheetApp.newDataValidation().requireValueInList(['Open','Done','Skipped'], true).build());
  bp.setFrozenRows(2);
  bp.setColumnWidth(1, 340); bp.setColumnWidth(2, 150); bp.setColumnWidth(3, 210); bp.setColumnWidth(6, 250);
  bp.hideColumns(BP.CID); // holds the source Closing ID for write-back
  var brng = bp.getRange('A3:F1000');
  bp.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$E3="Open"').setBackground(CLR.open).setRanges([brng]).build(),
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$E3="Done"').setBackground(CLR.done).setRanges([brng]).build(),
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$E3="Skipped"').setBackground(CLR.skip).setFontColor('#9E9E9E').setRanges([brng]).build()
  ]);
  bp.getRange('H1').setValue('Pick a property, then mark tasks Done or Skipped right here — it updates the Tasks tab automatically.').setFontColor(CLR.grey);

  // ---------- Template ----------
  var tp = getOrCreate_(ss, TAB.TEMPLATE);
  if (tp.getRange('A1').getValue() === '') {
    tp.getRange('A1:F1').setValues([['#','Item','Type','Owner','Due (days after close)','Auto-action']]);
    tp.getRange('A2:F11').setValues([
      [1,'Review request','⚡ Drafts it — you send',SALES,3,'REVIEW_DRAFT'],
      [2,'CRM: set to Past Client, update address, log close date (grab lockbox + sign if your listing)','Tell','Admin',1,''],
      [3,'Send closing gift / handwritten card','Tell',SALES,5,''],
      [4,'Add birthday + home-anniversary reminders in CRM','Tell','Admin',2,''],
      [5,'Add to Christmas / mailer list','⚡ Fully automatic',AUTO,1,'MAILING_LIST'],
      [6,'Add to newsletter','Enroll','Admin',1,''],
      [7,'Enroll in home-value reports (Homebot / LiveinHere)','Enroll','Admin',2,''],
      [8,'Add to CRM recurring touch cadence','Tell','Admin',1,''],
      [9,'Add to pop-by / neighborhood route','Tell',SALES,3,''],
      [10,'Log the closed deal in your production tracker (units + GCI)','Tell','Admin',1,'']
    ]);
  }
  styleHeader_(tp, 'A1:F1', CLR.template);
  tp.setFrozenRows(1);
  tp.setColumnWidth(2, 360); tp.setColumnWidth(3, 170); tp.setColumnWidth(4, 190); tp.setColumnWidth(8, 470);
  tp.getRange('D2:D200').setDataValidation(
    SpreadsheetApp.newDataValidation().requireValueInRange(ls.getRange('A1:A60'), true).build());
  tp.getRange('H2').setValue(
    'THIS TAB IS YOURS — EDIT IT FREELY\n\n' +
    '➕  Add a step: type a new row. Leave "Auto-action" blank.\n' +
    '➖  Remove a step: delete the row.\n' +
    '✏️  Reword, reassign, or re-time any step anytime.\n\n' +
    'OWNER options:\n' +
    '• A person — same teammate every closing.\n' +
    '• "' + SALES + '" — whoever is named on that closing’s row. One row covers a 50-agent team.\n' +
    '• "' + AUTO + '" — the script does it. No one is assigned.\n\n' +
    'The "#" and "Type" columns are notes for your eyes. Only "Auto-action" changes what happens — and it only understands REVIEW_DRAFT and MAILING_LIST.\n\n' +
    'Want a step to DO something new? That’s when you hand this script to Claude.')
    .setWrap(true).setVerticalAlignment('top').setFontColor(CLR.grey);
  tp.getRange('H2:H16').merge();
  band_(tp, 'A2:F11');
  // Grey out machine-run rows so nobody thinks a person owes them.
  tp.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$F2<>""')
      .setBackground(CLR.auto).setRanges([tp.getRange('A2:F200')]).build()
  ]);

  // ---------- Review Email (wording only now) ----------
  var rv = getOrCreate_(ss, TAB.REVIEW);
  if (rv.getRange('A1').getValue() === '') {
    rv.getRange('A1').setValue('Subject');
    rv.getRange('B1').setValue('One quick favor, {{Greeting}}?');
    rv.getRange('A2').setValue('Body');
    rv.getRange('B2').setValue(
'Hi {{Greeting}},\n\n' +
'Now that the dust has settled on {{PropertyAddress}}, I wanted to say again how much I enjoyed working with you.\n\n' +
'Can I ask you a huge favor? My online reviews play a huge role in how new clients find me and decide to trust me — and it would mean the world to me if you could take 60 seconds to leave a quick review of your experience.\n\n' +
'Here’s the link (and if it’s easy for you to leave one in more than one place, even better):\n\n' +
'{{ReviewLinks}}\n\n' +
'No pressure at all — and either way, congratulations again on {{PropertyAddress}}. You know where to find me if you ever need anything.\n\n' +
'{{AgentName}}');
  }
  styleHeader_(rv, 'A1:B1', CLR.review);
  rv.getRange('A2').setBackground(CLR.review).setFontColor('#ffffff').setFontWeight('bold');
  rv.getRange('B1:B2').setBackground(CLR.edit);
  rv.getRange('B2').setWrap(true).setVerticalAlignment('top');
  rv.getRange('D1').setValue(
    'Rewrite this however you like — it’s just text.\n\n' +
    'Merge fields:\n{{Greeting}}  {{ClientName}}  {{PropertyAddress}}\n{{ClosingDate}}  {{AgentName}}  {{ReviewLinks}}  {{ReviewPlatformNames}}\n\n' +
    'Your review links live on the Start Here tab.\n\n' +
    'This email is always saved as a DRAFT — never sent automatically. You add the personal line, then hit send.')
    .setWrap(true).setVerticalAlignment('top').setFontColor(CLR.grey);
  rv.getRange('D1:D10').merge();
  rv.setColumnWidth(1, 100); rv.setColumnWidth(2, 620); rv.setColumnWidth(4, 420);

  // ---------- colors, order, cleanup, triggers ----------
  st.setTabColor(CLR.start); cl.setTabColor(CLR.closings); tk.setTabColor(CLR.tasks);
  bp.setTabColor(CLR.tasks); tp.setTabColor(CLR.template); rv.setTabColor(CLR.review);
  order_(ss, [TAB.START, TAB.CLOSINGS, TAB.TASKS, TAB.BYPROP, TAB.TEMPLATE, TAB.REVIEW]);
  ['Sheet1','Sheet 1','People'].forEach(function (n) {
    var j = ss.getSheetByName(n);
    if (j && j.getLastRow() <= 1) { try { ss.deleteSheet(j); } catch (e) {} }
  });
  installTriggers_();
  ss.setActiveSheet(st);
  ss.toast('All set. Fill in the Start Here tab, then try "Seed sample test data".', '🏠 Closing Protocol', 6);
}

// ====================  OPEN A CLOSING  ====================
function onEditRouter(e) {
  if (!e || !e.range) return;
  var name = e.range.getSheet().getName();
  if (name === TAB.CLOSINGS && e.range.getColumn() === C.RUN &&
      e.range.getRow() > 1 && e.range.getValue() === true) {
    openClosing_(e.range.getSheet(), e.range.getRow());
  } else if (name === TAB.TASKS && e.range.getColumn() === T.STATUS && e.range.getRow() > 1) {
    onTaskStatusChange_(e.range.getSheet(), e.range.getRow());
  } else if (name === TAB.BYPROP && e.range.getColumn() === 2 && e.range.getRow() === 1) {
    populateByProperty_(e.range.getSheet());
  } else if (name === TAB.BYPROP && e.range.getColumn() === BP.STATUS && e.range.getRow() > 2) {
    byPropertyWriteBack_(e.range.getSheet(), e.range.getRow());
  }
}

function runSelectedClosing() {
  var sh = SpreadsheetApp.getActiveSheet();
  if (sh.getName() !== TAB.CLOSINGS) { SpreadsheetApp.getUi().alert('Click a row on the "Closings" tab first.'); return; }
  openClosing_(sh, sh.getActiveCell().getRow());
}

function openClosing_(sheet, row) {
  var ss = SpreadsheetApp.getActive();
  var v = sheet.getRange(row, 1, 1, C.WRAPPED).getValues()[0];
  var closing = {
    id: v[C.ID-1] || nextClosingId_(sheet),
    name: v[C.NAME-1], greet: v[C.GREET-1], address: v[C.ADDRESS-1],
    email: v[C.EMAIL-1], date: v[C.DATE-1], agent: v[C.AGENT-1]
  };
  if (!closing.address) { sheet.getRange(row, C.RUN).setValue(false); return; }
  sheet.getRange(row, C.ID).setValue(closing.id);

  var template = getTemplate_(), people = getPeople_();
  var tasks = ss.getSheetByName(TAB.TASKS);
  var byPerson = {}, newRows = [];

  template.forEach(function (item) {
    var who = resolveAssignee_(item.owner, closing, people);
    var due = addDays_(closing.date, item.offset);
    var status = 'Open', stamp = '', emailNote = '', notify = !who.auto;

    if (item.action === 'REVIEW_DRAFT') {
      emailNote = draftReview_(closing)
        ? '  (✏️ draft ready in Gmail — review & send)'
        : '  (⚠️ add a client email to draft it)';
    } else if (item.action === 'MAILING_LIST') {
      if (addToLabels_(closing)) {
        status = 'Done';
        stamp = 'Done automatically — added to Address Labels — ' + fmtDate_(new Date(), true);
        notify = false;
      }
    }
    // Future episode: if your production tracker is a Google Sheet, add a
    // 'PROD_TRACKER' action here that appends the closing the same way.

    newRows.push([closing.id, closing.address, item.item, item.type,
                  who.label, who.email, due, status, stamp]);
    if (notify && who.email) (byPerson[who.email] = byPerson[who.email] || []).push(
      '• ' + item.item + ' — due ' + fmtDate_(due) + emailNote);
  });

  if (newRows.length) tasks.getRange(tasks.getLastRow()+1, 1, newRows.length, 9).setValues(newRows);

  var agentName = getAgentName_();
  Object.keys(byPerson).forEach(function (email) {
    try {
      GmailApp.sendEmail(email,
        'New closing: ' + closing.address + ' — your tasks',
        'A new closing just opened.\n\n' + closing.name + ' — ' + closing.address +
        ' (closed ' + fmtDate_(closing.date) + ')\n\n' +
        'Here’s what’s assigned to you:\n\n' + byPerson[email].join('\n') +
        '\n\nMark each Done or Skipped on the Tasks tab:\n' + tasksUrl_() +
        '\n\n(First time? Make sure ' + agentName + ' has shared this sheet with you as an editor, or you won’t be able to check anything off.)' +
        '\n\n— ' + agentName);
    } catch (err) { /* a failed send never blocks the sheet from finishing */ }
  });

  sheet.getRange(row, C.STATUS).setValue('✅ Opened — ' + newRows.length + ' tasks (' + closing.id + ') — ' + fmtDate_(new Date(), true));
  sheet.getRange(row, C.WRAPPED).setValue('NO');
  sheet.getRange(row, C.RUN).setValue(false);
}

// Turn a Template "Owner" into a real person (or nobody, for automatic steps).
function resolveAssignee_(owner, closing, people) {
  var o = String(owner).trim();
  if (o.indexOf('Automatic') > -1) return { email:'', label:'⚡ Automatic', auto:true };
  var name = (o.indexOf('Sales Agent') > -1) ? String(closing.agent || '').trim() : o;
  var email = people[name.toLowerCase()] || getAgentEmail_();
  return { email:email, label:(name || 'Unassigned'), auto:false };
}

// Fill the By Property table with the picked closing's tasks (editable rows).
function populateByProperty_(bp) {
  var ss = SpreadsheetApp.getActive();
  var address = String(bp.getRange('B1').getValue()).trim();
  if (bp.getLastRow() > 2) bp.getRange(3, 1, bp.getLastRow()-2, 7).clearContent();
  if (!address) { bp.getRange('A3').setValue('← Pick a property in cell B1.').setFontColor(CLR.grey); return; }

  var rows = ss.getSheetByName(TAB.TASKS).getDataRange().getValues(), out = [];
  for (var i = 1; i < rows.length; i++) {
    if (String(rows[i][T.ADDRESS-1]).trim() === address) {
      out.push([rows[i][T.ITEM-1], rows[i][T.OWNER-1], rows[i][T.ASSIGNEE-1],
                rows[i][T.DUE-1], rows[i][T.STATUS-1], rows[i][T.UPDATED-1], rows[i][T.CID-1]]);
    }
  }
  if (!out.length) { bp.getRange('A3').setValue('No tasks found for that property.').setFontColor(CLR.grey); return; }
  bp.getRange(3, 1, out.length, 7).setValues(out);
}

// A Status change on By Property writes back to the matching Tasks row.
function byPropertyWriteBack_(bp, row) {
  var newStatus = bp.getRange(row, BP.STATUS).getValue();
  var item = String(bp.getRange(row, BP.ITEM).getValue()).trim();
  var cid  = String(bp.getRange(row, BP.CID).getValue()).trim();
  if (!item || !cid) return;

  var ss = SpreadsheetApp.getActive();
  var tk = ss.getSheetByName(TAB.TASKS), data = tk.getDataRange().getValues();
  for (var i = 1; i < data.length; i++) {
    if (String(data[i][T.CID-1]).trim() === cid && String(data[i][T.ITEM-1]).trim() === item) {
      tk.getRange(i+1, T.STATUS).setValue(newStatus);
      var stamp = (newStatus === 'Done' || newStatus === 'Skipped')
        ? newStatus + ' — ' + fmtDate_(new Date(), true) + ' (' + data[i][T.OWNER-1] + ')' : '';
      tk.getRange(i+1, T.UPDATED).setValue(stamp);
      bp.getRange(row, BP.UPDATED).setValue(stamp);   // echo it on this view
      maybeWrapClosing_(cid);
      return;
    }
  }
}

// ====================  REVIEW DRAFT  ====================
function draftReview_(closing) {
  var recips = String(closing.email || '').split(/[;,]/).map(function (s) { return s.trim(); }).filter(String).join(',');
  if (!recips) return false;
  var ss = SpreadsheetApp.getActive();
  var rv = ss.getSheetByName(TAB.REVIEW);
  var platforms = getPlatforms_();
  var data = {
    Greeting: closing.greet || closing.name, ClientName: closing.name,
    PropertyAddress: closing.address, ClosingDate: fmtDate_(closing.date),
    AgentName: getAgentName_(),
    ReviewLinks: platforms.map(function (p) { return p.name + ': ' + p.link; }).join('\n'),
    ReviewPlatformNames: naturalJoin_(platforms.map(function (p) { return p.name; }))
  };
  GmailApp.createDraft(recips, fillTemplate_(labelValue_(rv,'Subject'), data), fillTemplate_(labelValue_(rv,'Body'), data));
  return true;
}

// ====================  TASK COMPLETION  ====================
function onTaskStatusChange_(sheet, row) {
  var status = sheet.getRange(row, T.STATUS).getValue();
  var owner = sheet.getRange(row, T.OWNER).getValue();
  if (status === 'Done' || status === 'Skipped') {
    sheet.getRange(row, T.UPDATED).setValue(status + ' — ' + fmtDate_(new Date(), true) + ' (' + owner + ')');
    maybeWrapClosing_(sheet.getRange(row, T.CID).getValue());
  } else {
    sheet.getRange(row, T.UPDATED).setValue('');
  }
}

function maybeWrapClosing_(closingId) {
  if (!closingId) return;
  var ss = SpreadsheetApp.getActive();
  var tasks = ss.getSheetByName(TAB.TASKS).getDataRange().getValues();
  var anyOpen = false, address = '';
  for (var i = 1; i < tasks.length; i++) {
    if (tasks[i][T.CID-1] === closingId) {
      address = tasks[i][T.ADDRESS-1];
      if (tasks[i][T.STATUS-1] === 'Open') anyOpen = true;
    }
  }
  if (anyOpen) return;
  var cl = ss.getSheetByName(TAB.CLOSINGS), rows = cl.getDataRange().getValues();
  for (var r = 1; r < rows.length; r++) {
    if (rows[r][C.ID-1] === closingId) {
      if (rows[r][C.WRAPPED-1] === 'YES') return;
      cl.getRange(r+1, C.WRAPPED).setValue('YES');
      cl.getRange(r+1, C.STATUS).setValue('🎉 Complete — ' + fmtDate_(new Date(), true));
      GmailApp.sendEmail(getAgentEmail_(), '🎉 ' + address + ' — closing protocol complete',
        'Every task for ' + address + ' is done or intentionally skipped. Nice work.\n\n' + ss.getUrl());
      return;
    }
  }
}

// ====================  WEEKLY REMINDER  ====================
function weeklyOutstanding() {
  var ss = SpreadsheetApp.getActive();
  var rows = ss.getSheetByName(TAB.TASKS).getDataRange().getValues();
  var today = new Date(); today.setHours(0,0,0,0);
  var byPerson = {};
  for (var i = 1; i < rows.length; i++) {
    if (rows[i][T.STATUS-1] !== 'Open') continue;
    var who = rows[i][T.ASSIGNEE-1]; if (!who) continue;
    var due = rows[i][T.DUE-1];
    var overdue = (due instanceof Date) && due < today ? '  ‼️ OVERDUE' : '';
    (byPerson[who] = byPerson[who] || []).push('• ' + rows[i][T.ADDRESS-1] + ' — ' + rows[i][T.ITEM-1] + ' — due ' + fmtDate_(due) + overdue);
  }
  var agentName = getAgentName_();
  Object.keys(byPerson).forEach(function (email) {
    GmailApp.sendEmail(email, '⏰ Outstanding closing tasks (' + byPerson[email].length + ' open)',
      'These closing tasks are still open:\n\n' + byPerson[email].join('\n') +
      '\n\nMark them Done or Skipped here:\n' + tasksUrl_() + '\n\n— ' + agentName);
  });
  SpreadsheetApp.getActive().toast('Weekly reminders sent to ' + Object.keys(byPerson).length + ' people.', '🏠 Closing Protocol', 5);
}

// ====================  SEED + RESET  ====================
function seedTestData() {
  var ss = SpreadsheetApp.getActive();
  if (!ss.getSheetByName(TAB.CLOSINGS)) setUp();
  var me = Session.getActiveUser().getEmail(), ui = SpreadsheetApp.getUi();
  if (ui.alert('Seed test data',
    'Adds 2 sample closings (client email = YOU: ' + me + ') and runs the protocol on both, so all emails land in your own inbox.\n\n' +
    'Assignment emails go to the people on your Start Here tab — keep those pointed at yourself while testing. Continue?',
    ui.ButtonSet.OK_CANCEL) !== ui.Button.OK) return;

  var st = ss.getSheetByName(TAB.START);
  var firstPerson = st.getRange(SH.PEOPLE_FIRST, 1).getValue() || '';
  var cl = ss.getSheetByName(TAB.CLOSINGS), start = firstEmptyClosingRow_(cl), today = new Date();
  cl.getRange(start, C.NAME, 2, 6).setValues([
    ['Thomas & Sarah Jones','Thomas & Sarah','123 Test Lane, Gilbert, AZ 85296', me, today, firstPerson],
    ['The Miller Family','','456 Demo Dr, Chandler, AZ 85224', me, today, firstPerson]
  ]);
  openClosing_(cl, start); openClosing_(cl, start + 1);
  SpreadsheetApp.flush();
  ss.setActiveSheet(cl);
  ss.toast('Seeded 2 closings. They’re on THIS tab; their tasks are on Tasks. Check your inbox + Address Labels file too.', '🏠 Closing Protocol', 7);
}

/**
 * RECORDING MODE - makes every email type land in your inbox right now.
 *
 * Nothing here is faked. It simply adds closings that are DATED IN THE PAST,
 * so their tasks are already past due - which is exactly what the weekly
 * reminder is built to catch. Then it runs the weekly reminder on the spot.
 *
 * You'll end up with, in your inbox:
 *   1. Two "new closing - your tasks" assignment emails
 *   2. Two review-request DRAFTS (in Gmail > Drafts)
 *   3. One "outstanding closing tasks" reminder, with OVERDUE flags
 *   4. One "closing protocol complete" celebration email
 */
function demoGenerateAllEmails() {
  var ss = SpreadsheetApp.getActive();
  if (!ss.getSheetByName(TAB.CLOSINGS)) setUp();
  var me = Session.getActiveUser().getEmail(), ui = SpreadsheetApp.getUi();
  if (ui.alert('🎬 Demo mode',
    'This will REALLY SEND emails to the people on your Start Here tab. Before recording, point every email there at yourself.\n\n' +
    'It adds two backdated closings so you can show:\n' +
    '• assignment emails\n• review drafts\n• an overdue weekly reminder\n• the all-done celebration email\n\n' +
    'Continue?', ui.ButtonSet.OK_CANCEL) !== ui.Button.OK) return;

  var st = ss.getSheetByName(TAB.START);
  var firstPerson = st.getRange(SH.PEOPLE_FIRST, 1).getValue() || '';
  var cl = ss.getSheetByName(TAB.CLOSINGS), start = firstEmptyClosingRow_(cl);

  // Closing 1: closed 10 days ago -> its tasks are already overdue.
  // Closing 2: closed 12 days ago -> we'll mark it fully complete.
  cl.getRange(start, C.NAME, 2, 6).setValues([
    ['Thomas & Sarah Jones','Thomas & Sarah','123 Test Lane, Gilbert, AZ 85296', me, addDays_(new Date(), -10), firstPerson],
    ['The Miller Family','','456 Demo Dr, Chandler, AZ 85224', me, addDays_(new Date(), -12), firstPerson]
  ]);
  openClosing_(cl, start);       // assignment email + review draft
  openClosing_(cl, start + 1);   // assignment email + review draft

  var overdueId = cl.getRange(start, C.ID).getValue();
  var completeId = cl.getRange(start + 1, C.ID).getValue();

  // Leave closing 1 open (overdue). Close out every task on closing 2.
  var tk = ss.getSheetByName(TAB.TASKS), rows = tk.getDataRange().getValues();
  for (var i = 1; i < rows.length; i++) {
    if (rows[i][T.CID-1] !== completeId) continue;
    if (rows[i][T.STATUS-1] === 'Done') continue;                 // the automatic one
    var mark = (i % 5 === 0) ? 'Skipped' : 'Done';                // show off both statuses
    tk.getRange(i+1, T.STATUS).setValue(mark);
    tk.getRange(i+1, T.UPDATED).setValue(mark + ' — ' + fmtDate_(new Date(), true) + ' (' + rows[i][T.OWNER-1] + ')');
  }
  SpreadsheetApp.flush();
  maybeWrapClosing_(completeId);  // celebration email

  weeklyOutstanding();            // overdue reminder for closing 1

  ss.toast('Demo complete. Check your inbox (4 emails) and Gmail Drafts (2 review drafts).', '🎬 Closing Protocol', 8);
}

function resetSystem() {
  var ss = SpreadsheetApp.getActive(), ui = SpreadsheetApp.getUi();
  if (ui.alert('Reset',
    'Clear all rows on Closings and Tasks? Your Start Here, Template, and Review Email tabs stay untouched.\n\n' +
    '(Test review DRAFTS in Gmail, and rows in your Address Labels file, aren’t deleted — remove those manually if you like.)',
    ui.ButtonSet.OK_CANCEL) !== ui.Button.OK) return;
  [TAB.CLOSINGS, TAB.TASKS].forEach(function (n) {
    var sh = ss.getSheetByName(n);
    if (sh && sh.getLastRow() > 1) sh.getRange(2,1,sh.getLastRow()-1, sh.getLastColumn()).clearContent();
  });
  ss.toast('Cleared Closings + Tasks. Ready for a clean test.', '🏠 Closing Protocol', 5);
}

/**********************************************************************
 *  DOWN HERE:  ENGINE HELPERS - you shouldn't need to touch these.
 **********************************************************************/
function getTemplate_() {
  var sh = SpreadsheetApp.getActive().getSheetByName(TAB.TEMPLATE);
  return sh.getRange(2, 1, Math.max(sh.getLastRow()-1,1), 6).getValues()
    .filter(function (r) { return r[1] !== ''; })
    .map(function (r) { return { item:r[1], type:r[2], owner:r[3], offset:Number(r[4])||0, action:String(r[5]).trim() }; });
}
function getPeople_() {
  var st = SpreadsheetApp.getActive().getSheetByName(TAB.START);
  var v = st.getRange(SH.PEOPLE_FIRST, 1, SH.PEOPLE_LAST-SH.PEOPLE_FIRST+1, 2).getValues(), map = {};
  v.forEach(function (r) { if (r[0] && r[1]) map[String(r[0]).trim().toLowerCase()] = String(r[1]).trim(); });
  return map;
}
function getPlatforms_() {
  var st = SpreadsheetApp.getActive().getSheetByName(TAB.START);
  var v = st.getRange(SH.LINKS_FIRST, 1, SH.LINKS_LAST-SH.LINKS_FIRST+1, 2).getValues(), out = [];
  v.forEach(function (r) {
    var n = String(r[0]).trim(), l = String(r[1]).trim();
    if (n && l && l.indexOf('PASTE') === -1) out.push({ name:n, link:l });
  });
  return out;
}
function getAgentName_()  { return labelValue_(SpreadsheetApp.getActive().getSheetByName(TAB.START), 'Your name (signs your emails)') || 'Your Agent'; }
function getAgentEmail_() { return labelValue_(SpreadsheetApp.getActive().getSheetByName(TAB.START), 'Your email (gets the all-done notice)') || Session.getActiveUser().getEmail(); }

function labelValue_(sheet, label) {
  var data = sheet.getRange(1,1,Math.min(sheet.getLastRow(),80),2).getValues();
  for (var i = 0; i < data.length; i++) if (String(data[i][0]).trim() === label) return data[i][1];
  return '';
}
function fillTemplate_(s, data) { return String(s).replace(/{{(\w+)}}/g, function (m, k) { return data[k] != null ? data[k] : ''; }); }
function nextClosingId_(sheet) {
  var ids = sheet.getRange(2,1,Math.max(sheet.getLastRow()-1,1),1).getValues(), max = 0;
  ids.forEach(function (r) { var n = parseInt(String(r[0]).replace('C',''),10); if (n>max) max=n; });
  return 'C' + ('000'+(max+1)).slice(-3);
}
function addDays_(date, days) { var d = (date instanceof Date) ? new Date(date.getTime()) : new Date(date); d.setDate(d.getDate()+days); return d; }
function fmtDate_(d, withTime) {
  if (!(d instanceof Date)) { var t = new Date(d); if (isNaN(t)) return d; d = t; }
  return Utilities.formatDate(d, SpreadsheetApp.getActive().getSpreadsheetTimeZone(), withTime ? 'MMM d, h:mm a' : 'MMM d, yyyy');
}
function naturalJoin_(a) { return a.length<=1 ? a.join('') : a.length===2 ? a[0]+' and '+a[1] : a.slice(0,-1).join(', ')+', and '+a[a.length-1]; }
function tasksUrl_() { var ss = SpreadsheetApp.getActive(); return ss.getUrl() + '#gid=' + ss.getSheetByName(TAB.TASKS).getSheetId() + '&range=A1'; }
function getOrCreate_(ss, name) { return ss.getSheetByName(name) || ss.insertSheet(name); }

// The real next-open row on Closings. Checkbox cells count as "content" and
// inflate getLastRow(), so we look at the Client Name column instead.
function firstEmptyClosingRow_(cl) {
  var last = cl.getLastRow();
  if (last < 2) return 2;
  var names = cl.getRange(2, C.NAME, last - 1, 1).getValues();
  for (var i = names.length - 1; i >= 0; i--) {
    if (String(names[i][0]).trim() !== '') return i + 3;
  }
  return 2;
}
function styleHeader_(sheet, a1, bg) { sheet.getRange(a1).setBackground(bg).setFontColor('#ffffff').setFontWeight('bold'); }
function band_(sheet, a1) { try { sheet.getRange(a1).applyRowBanding(SpreadsheetApp.BandingTheme.LIGHT_GREY, false, false); } catch (e) {} }
function order_(ss, names) { names.forEach(function (n, i) { var sh = ss.getSheetByName(n); if (sh) { ss.setActiveSheet(sh); ss.moveActiveSheet(i+1); } }); }

function getLabelsFile_() {
  var props = PropertiesService.getDocumentProperties();
  var id = props.getProperty('LABELS_FILE_ID');
  if (id) { try { return SpreadsheetApp.openById(id); } catch (e) { /* deleted - recreate */ } }
  var f = SpreadsheetApp.create('Address Labels — Christmas Card List');
  var sh = f.getSheets()[0]; sh.setName('Mailing List');
  sh.getRange('A1:C1').setValues([['Name','Mailing Address','Added']])
    .setBackground(CLR.closings).setFontColor('#ffffff').setFontWeight('bold');
  sh.setFrozenRows(1); sh.setColumnWidth(1, 220); sh.setColumnWidth(2, 320);
  props.setProperty('LABELS_FILE_ID', f.getId());
  return f;
}
function addToLabels_(closing) {
  try {
    var sh = getLabelsFile_().getSheetByName('Mailing List');
    var data = sh.getDataRange().getValues();
    for (var i = 1; i < data.length; i++) {
      if (String(data[i][0]).trim().toLowerCase() === String(closing.name).trim().toLowerCase() &&
          String(data[i][1]).trim().toLowerCase() === String(closing.address).trim().toLowerCase()) return true;
    }
    sh.appendRow([closing.name, closing.address, fmtDate_(new Date())]);
    return true;
  } catch (e) { return false; }
}
function installTriggers_() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    var f = t.getHandlerFunction();
    if (f === 'onEditRouter' || f === 'weeklyOutstanding') ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger('onEditRouter').forSpreadsheet(SpreadsheetApp.getActive()).onEdit().create();
  ScriptApp.newTrigger('weeklyOutstanding').timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8).create();
}

Run it from the editor the first time — that’s what triggers the permission prompt. After that you drive everything from the sheet’s own Closing Protocol menu.

Step 3. Set it up on the Start Here tab

  • Your info — your name, which signs the emails, and your email, which gets the “all done” notice.
  • Your people — everyone who can be handed a task. Solo agent? Just list yourself. Team? A row for each person, including every sales agent.
  • Your review links — paste your real Google, Zillow or other links over the red placeholders. Any row still saying PASTE is skipped.

Everything you’ll ever change lives on a tab. You never edit the code to customise this.

The six tabs

TabWhat it is
Start HereEverything you configure: your info, your people, your review links.
ClosingsYour daily driver. Add a closing, fill the row, check the box.
TasksThe checklist that appears automatically. Mark items Done or Skipped.
By PropertyPick one closing and see — and update — just its tasks.
TemplateYour SOP: the steps, who owns each one, and when they are due.
Review EmailThe exact wording of the review request. Edit it freely.

Step 4. Make it yours: the Template tab

The Template tab is your SOP, written in plain English. The code simply reads whatever rows are there — it doesn’t care whether there are ten or thirty.

  • Add a step: type a new row and leave the Auto-action column blank.
  • Remove a step: delete the row.
  • Reword, reassign or re-time any step, any time.

Who owns a step

Owner settingWhat it means
A person’s nameThe same teammate every closing — your transaction coordinator always does the CRM update.
Sales Agent (this closing)Whoever is named in the Sales Agent column on that closing’s row. One template row covers a whole team of agents.
Automatic (no one)The script does it. Nobody is assigned and no reminder is sent.

The three kinds of step

Do — the computer performs it. Tell — it reminds a person to do it. Enroll — it drops the client into a system you already run. Only the Auto-action column changes what actually happens, and it understands just two words: REVIEW_DRAFT and MAILING_LIST. Everything else is a tracked reminder.

The boundary worth remembering. Add, delete, rename, reassign or re-time any step — all on the spreadsheet, no code. The one thing you can’t type your way into is making a step do something brand new. That’s the moment you hand the script to Claude.

Step 5. Try it, then reset

  • Seed sample test data adds two sample closings and runs them, with every email routed to your own inbox.
  • Demo: generate every email backdates closings so their tasks are already overdue — the honest way to see the weekly reminder and the completion email on demand.
  • Reset clears the Closings and Tasks tabs; your setup stays. Always clear test data with Reset rather than deleting rows by hand, so no orphan tasks are left behind.

Step 6. Share it with your team

Don’t skip this. Share the sheet with your team as Editors. If a teammate can’t open the sheet, they can’t check anything off — and the accountability loop quietly dies. This is the most common reason a team setup “doesn’t work”.

Step 7. Honest limits

  • The review email is always a draft, never auto-sent. The machine won’t email your client without you looking first. Internal reminders do send.
  • The card list uses the property address, which is right for buyers. For sellers, update it once you know where they’ve moved.
  • The By Property view writes back to the Tasks tab when you edit it. If you edit Tasks directly, re-pick the property to refresh the view.
  • You check the box. A human pulls the trigger on each closing — the system never fires on its own.

Step 8. Extend it with Claude

When you want a step to do something new — log the deal in your production tracker, add a date to your calendar, let people reply “done” to complete a task — you don’t start over. You paste this script to Claude, describe the step in plain English, and let it write the new action. There’s already a comment in the code marking exactly where a production-tracker action would go.

That’s the real lesson

This was never only about closings. The same method turns any checklist — listing intake, buyer consults, transaction milestones, new-agent onboarding — into a workflow that runs itself.

Questions agents ask

Before you paste the code.

Is it safe to paste code I didn’t write?

It runs inside your own Google account, on your own sheet, and nothing leaves it. Google’s “unverified app” warning says exactly that — it’s your script, not a published one. Every line is above, so you can read it, or hand it to Claude and ask what it does, before you paste.

Does it email my client automatically?

No. The review request is always saved as a Gmail draft for you to read and send. The only emails that send on their own are the internal ones to your own team.

Do I need Google Workspace?

No. A free Gmail account works, and there’s no cost at any point.

What happens if two closings land the same day?

Each one gets its own dated, owned set of tasks keyed to a closing ID, so they never collide — and the “all done” email fires per closing, not per day.

Prefer Google over your inbox?

Tell Google you want Kristi’s builds first

Add kristijencks.com as a preferred source and Google highlights new Claude for Agents builds and Take It and Run articles in your Top Stories and AI results.

Or have it built for you

Want one of these built for your business instead of building it yourself?

A working session with Kristi or Merrill ends with a system running, not a list of notes.

Book an AI Session