Blog / Small Business Automation

Proposal Follow-Up for High-Ticket Service Firms (Architects, Engineers, Specialty Trades)

By Keyona Meeks, Founder, ReRev Labs · Oct 31, 2026 · 9 min read

The short answer: Most proposals go quiet. The fix is a plain habit: write down the date of the next follow-up the day you send the proposal, and look at the list every morning. A Google Sheet holds one row per proposal. A formula sets the next follow-up date from the last contact. A short Apps Script emails you the proposals due, so you write the follow-up yourself. It never emails a client. I tested the script with a mock in Node and the formulas in LibreOffice. Nothing was run in Google.

By the end of this post, you'll be able to:

  • Set up a proposal tracker with a next-follow-up date.
  • Get a daily email of proposals due for a follow-up.
  • Write a short, honest follow-up message.
  • Know what this tracker cannot do.

We sell operations builds, and we rebuild a few tools for free (see the end of this post), so weigh this post with that in mind. This is for firms that sell high-ticket services, such as architects, engineers and specialty trades, and send owner-side proposals. I have no data on how often proposals get answered, so I give no numbers.

How do you set up the tracker?

Row 1 holds headings. Use a tab named Proposals.

ColumnHeadingWhat goes in it
AClientWho the proposal went to
BProjectA short name
CSentThe date you sent it
DLast contactThe date of your last real exchange, blank if none
ENext follow-upFormula
FStatusLeave blank or type Open while live. Type Won or Lost when it ends
GFlagFormula

Put the number of days between follow-ups in cell J1. That number is your decision. I tested with 7, which is an example, not advice. Put these in row 2 and fill them down:

E2: =IF(A2="","",IF(D2="",C2+$J$1,D2+$J$1))
G2: =IF(A2="","",IF(OR(F2="Won",F2="Lost"),"Closed",IF(E2<=TODAY(),"Follow up","Waiting")))

The next date counts from your last contact, or from the sent date when there has been none. A date of today shows "Follow up."

I tested these in LibreOffice with five made-up rows and a gap of 7 days. They returned Follow up, Waiting, Closed, Waiting and Follow up, and the boundary row (exactly 7 days after the last contact) showed Follow up. I did not run them in Google Sheets, so test with your own rows.

What does the script look like?

Paste this into the Apps Script project attached to the sheet. It reads the tab and changes nothing. Nothing in it runs until you call it.

// Emails the proposals that are due for a follow-up. It reads the "Proposals" tab and changes nothing. It never emails a client.
var RECIPIENT = '';       // Paste the address that should get the list. Your decision.
var SEND_HOUR = 7;        // Hour of day, in the script time zone.
var TAB = 'Proposals';
var MAX_LINES = 40;       // Most lines in the email.
var CLOSED = ['won', 'lost', 'closed']; // Rows with these Status words are skipped. Your decision.
var HEADERS = {           // Change the text on the right to match YOUR column headings.
  client: 'Client', project: 'Project', next: 'Next follow-up', status: 'Status'
};

function dayKey(value, tz) {
  if (value === '' || value === null || value === undefined) return '';
  if (Object.prototype.toString.call(value) === '[object Date]') return Utilities.formatDate(value, tz, 'yyyy-MM-dd');
  return String(value).slice(0, 10);
}

function dayNumber(key) {
  var p = key.split('-');
  return Math.round(Date.UTC(Number(p[0]), Number(p[1]) - 1, Number(p[2])) / 86400000);
}

function findColumns(headerRow) {
  var idx = {};
  Object.keys(HEADERS).forEach(function (k) {
    var at = headerRow.map(function (h) { return String(h).trim(); }).indexOf(HEADERS[k]);
    if (at === -1) throw new Error('Missing column in ' + TAB + ': ' + HEADERS[k]);
    idx[k] = at;
  });
  return idx;
}

function findDue(rows, idx, todayKey, tz) {
  var due = [];
  var waiting = 0;
  var noDate = 0;
  var today = dayNumber(todayKey);
  rows.forEach(function (r) {
    if (String(r[idx.client]).trim() === '') return;
    if (CLOSED.indexOf(String(r[idx.status]).trim().toLowerCase()) !== -1) return;
    var key = dayKey(r[idx.next], tz);
    if (key === '' || isNaN(dayNumber(key))) { noDate++; return; }
    var left = dayNumber(key) - today;
    if (left <= 0) due.push({ client: r[idx.client], project: r[idx.project], next: key, late: -left });
    else waiting++;
  });
  due.sort(function (a, b) { return b.late - a.late; });
  return { due: due, waiting: waiting, noDate: noDate };
}

function buildEmail(result, todayKey) {
  var lines = ['Proposals due for a follow-up, as of ' + todayKey, ''];
  if (result.due.length === 0) {
    lines.push('None.');
  } else {
    result.due.slice(0, MAX_LINES).forEach(function (o) {
      lines.push(o.client + '  ' + o.project + '  (follow-up date ' + o.next + (o.late > 0 ? ', ' + o.late + ' days ago' : ', today') + ')');
    });
    if (result.due.length > MAX_LINES) lines.push('Plus ' + (result.due.length - MAX_LINES) + ' more.');
  }
  lines.push('', 'Open proposals not due yet: ' + result.waiting, 'Open proposals with no follow-up date: ' + result.noDate);
  return lines.join('\n');
}

function sendFollowUpList() {
  if (!RECIPIENT) throw new Error('Set RECIPIENT first.');
  if (MailApp.getRemainingDailyQuota() < 1) throw new Error('No email quota left today.');
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(TAB);
  if (!sheet) throw new Error('Missing tab: ' + TAB);
  var values = sheet.getDataRange().getValues();
  if (values.length === 0) throw new Error('The ' + TAB + ' tab is empty.');
  var idx = findColumns(values[0]);
  var tz = Session.getScriptTimeZone();
  var todayKey = Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd');
  var result = findDue(values.slice(1), idx, todayKey, tz);
  MailApp.sendEmail(RECIPIENT, 'Proposal follow-ups due: ' + result.due.length, buildEmail(result, todayKey));
  return result;
}

function installTrigger() {
  ScriptApp.newTrigger('sendFollowUpList')
    .timeBased()
    .everyDays(1)
    .atHour(SEND_HOUR)
    .inTimezone(Session.getScriptTimeZone())
    .create();
}

What the parts do, with Google's own descriptions as I read them in the method tables this session:

  • getActiveSpreadsheet(): "Returns the currently active spreadsheet, or null if there is none." getSheetByName(name): "Returns a sheet with the given name." getDataRange(): "Returns a Range corresponding to the dimensions in which data is present." getValues(): "Returns the rectangular grid of values for this range."
  • Utilities.formatDate "Formats date according to specification described in Java SE SimpleDateFormat class." Session.getScriptTimeZone() "Gets the time zone of the script."
  • MailApp.sendEmail(recipient, subject, body) "Sends an email message." getRemainingDailyQuota() "Returns the number of recipients you can send emails to for the rest of the day."
  • everyDays(n) "Specifies to run the trigger every n days." atHour(hour) "Specifies the hour the trigger at which the trigger runs." inTimezone(timezone) "Specifies the timezone for the specified dates/time when the trigger runs."

The script reads the Next follow-up column, which holds a formula, so it sees the value the formula produced. It skips rows whose Status is Won, Lost or Closed, lists the rest whose date is today or earlier, oldest first, and counts open proposals that are not due yet or have no date. When nothing is due it still sends a short "None" email, so you know it ran. The script emails you and nobody else.

Who should get the email?

You decide. RECIPIENT is a paste value, and the script will not run until you set it. For a shared list, ask the people involved first.

How do you put it on a daily schedule?

Run installTrigger once and check your project's triggers list for exactly one entry. I could not confirm which minute of the hour a trigger fires, so do not expect 7:00 sharp. Per an earlier review of Google's triggers page, installable triggers "always run under the account of the person who created them." Per an earlier review of Google's authorization page, scopes are "determined automatically by scanning the script code." Read the permission prompt before you approve it.

What should a follow-up say?

Short, specific and easy to answer. Here is a plain example you can change:

Hi [Name], I wanted to check in on the [project] proposal I sent on [date]. Is there anything you need from us to make a decision, such as a revised scope or a call with the team? If the timing has changed, tell me and I will note it. Thank you.

It asks one question, offers help and makes it easy to say "not now." Do not invent urgency. Do not claim a deadline that does not exist. For more on the follow-up habit, see the follow-up engine.

How do you test it?

  1. Enter five test lines: one due last week, one due today, one due next week, one marked Won and one with no dates.
  2. Check the Next follow-up and Flag columns by hand.
  3. Set RECIPIENT to your own address and run sendFollowUpList. Only the first two lines should appear.
  4. Change a heading by one letter. You should see the missing-heading message. Fix it.
  5. Run installTrigger and check the triggers list, then delete the test lines.

What did the test show?

I tested the script in Node with a mock of the Google services. All 15 checks passed. They covered due, due-today and not-yet-due lines, Won, Lost and Closed rows being skipped in any capitalization, oldest first, the waiting and no-date counts, date cells and text dates, a "None" email, a missing heading, an empty tab, a blank recipient, a used-up quota, a missing tab, the 40-line cap and the trigger chain. The mock cannot show how Google returns your dates, so steps 1 to 3 matter.

Where does an LLM fit in this?

An LLM can help with the messy input. For example, it can read the email thread with a client and suggest a first draft of the follow-up that refers to what was actually discussed. A person must read and edit every draft before it goes out, because an LLM can invent a detail or promise something, and sound sure. The dates and the daily list are plain rules. Do not paste confidential client documents into an LLM tool unless your contract and your own rules allow it. My stance: AI writes the code, plain automation runs it, and an LLM shows up only where the input is messy and a person checks the result. For where to start with your own automations, see what to automate first.

Where this has limits

  • It does not send anything to clients. You write and send each follow-up.
  • The gap between follow-ups is your call. I give no recommended number.
  • It cannot tell if a client replied. You update the Last contact date.
  • Nothing was run in Google. The script test was a mock in Node. The formula test was in LibreOffice.
  • Email quotas apply. I quote the quota call above. Check Google's limits for your account type.
  • I make no claim about win rates. I have no data.
  • We sell this kind of build. No ReRev prices and no promises about results.

We will rebuild one of Linktree, Typeform, Calendly, Bitly or Streak for free, in your own accounts. It starts with a 40-minute conversation, and we are open to 10 teams. If a proposal tracker is the tool you would rather have, tell us on the contact page. Do not send client documents, passwords or login details.

Frequently asked questions

How soon should I follow up on a proposal?

That is your call. A common habit is to set a date when you send it. I have no data on the best gap.

What should a proposal follow-up email say?

Ask one clear question, offer help and make it easy to say not now. The post has a plain example.

Does the script email my clients?

No. It emails you a list. You write each follow-up.

Can an LLM write the follow-up?

It can draft from the thread. A person reads and edits every draft.

What if a client asks me to stop?

Stop, and mark the row Lost or Closed. Ask an attorney if you have a question about the rules for your messages.

Is this the same as a CRM?

No. It is a list with dates and a daily email. A CRM does more.

Read next: Construction bid follow-up is the same follow-up system with construction-specific templates and a tracker, and a deal pipeline inside Gmail tracks proposals where the emails already are.