Setup guide · Mac

Set up Partner Momentum

Takes about 10-15 minutes. You'll need a Google account. No software to install.

1
Step one

Create your Google Sheet

Partner Momentum stores your pipeline in a Google Sheet that you own. Your data never leaves your Google account.

  • 1
    Go to sheets.google.com and create a new blank spreadsheet. Sign in with your Google account if prompted.
  • 2
    Right-click the tab at the bottom (currently "Sheet1") and select Rename. Type exactly: Pipeline
  • 3
    In row 1 of the Pipeline tab, enter these column headers exactly, one per column starting in cell A1: ID, OrgName, ContactName, ContactRole, Stage, LogoURL, AskType, AskAmount, LastContactDate, NextStep, Priority, Tags, LinkedIn, NotesHistory, AddedAt, StageChangedAt
  • 4
    Right-click the Pipeline tab again and select Duplicate. Rename the copy to exactly: Archive. Delete any rows below the header so Archive starts empty.
Both tab names must be spelled exactly as shown, "Pipeline" and "Archive," each with a capital letter. If they're spelled differently, the app will not work.
2
Step two

Add the Apps Script

Google Apps Script is a free tool built into Google Sheets. Paste the script below exactly as written.

  • 1
    In your Google Sheet, click Extensions > Apps Script. A new browser tab will open.
  • 2
    Click inside the code editor. Press Cmd+A to select all, then Delete to clear it.
  • 3
    Copy the code below and paste it into the editor with Cmd+V.
CODE TO PASTE
/**
 * PARTNER MOMENTUM — Apps Script Backend
 * Sheet tabs required: "Pipeline" (active/working cards) and "Archive" (auto-archived Declined cards)
 * Both tabs must use the exact column order below.
 */

// ---- Column order (Pipeline and Archive tabs share this schema) ----
var COLUMNS = [
  "ID", "OrgName", "ContactName", "ContactRole", "Stage",
  "LogoURL", "AskType", "AskAmount", "LastContactDate", "NextStep",
  "Priority", "Tags", "LinkedIn", "NotesHistory", "AddedAt", "StageChangedAt"
];

var ARCHIVE_AFTER_DAYS = 30;
var DECLINED_STAGE = "Declined";
var ARCHIVE_SHEET_NAME = "Archive";
var PIPELINE_SHEET_NAME = "Pipeline";

function doGet(e) { return handleRequest(e); }

function doPost(e) {
  var p = JSON.parse(e.postData.contents);
  return handleAction(p);
}

function handleRequest(e) {
  var p = e.parameter;
  return handleAction(p);
}

function handleAction(p) {
  var out = {};

  if (p.action === "headers") {
    out = { headers: COLUMNS };

  } else if (p.action === "read") {
    runArchiveSweep(); // catch overdue Declined cards on every load
    var sheet = getSheet(PIPELINE_SHEET_NAME);
    var rows = sheet.getDataRange().getValues();
    out = { rows: rows };

  } else if (p.action === "read_archive") {
    var archiveSheet = getSheet(ARCHIVE_SHEET_NAME);
    var rows = archiveSheet.getDataRange().getValues();
    out = { rows: rows };

  } else if (p.action === "append") {
    var sheet = getSheet(PIPELINE_SHEET_NAME);
    var row = JSON.parse(p.row);
    // AddedAt and StageChangedAt stamped server-side
    row[14] = new Date().toISOString();
    row[15] = new Date().toISOString();
    sheet.appendRow(row);
    out = { success: true };

  } else if (p.action === "update") {
    var sheet = getSheet(PIPELINE_SHEET_NAME);
    sheet.getRange(parseInt(p.row), parseInt(p.col)).setValue(p.value);
    out = { success: true };

  } else if (p.action === "move_stage") {
    // Dedicated action for stage changes so StageChangedAt always updates,
    // which is what drives the 30-day archive timer.
    var sheet = getSheet(PIPELINE_SHEET_NAME);
    var rowNum = parseInt(p.row);
    var stageCol = COLUMNS.indexOf("Stage") + 1;
    var stageChangedCol = COLUMNS.indexOf("StageChangedAt") + 1;
    sheet.getRange(rowNum, stageCol).setValue(p.newStage);
    sheet.getRange(rowNum, stageChangedCol).setValue(new Date().toISOString());
    out = { success: true };

  } else if (p.action === "clear") {
    var sheet = getSheet(PIPELINE_SHEET_NAME);
    sheet.getRange(parseInt(p.row), 1, 1, COLUMNS.length).clearContent();
    out = { success: true };

  } else if (p.action === "archive_sweep") {
    // Manual trigger, in addition to the automatic sweep on "read"
    var moved = runArchiveSweep();
    out = { success: true, archived: moved };

  } else if (p.action === "ai_suggest") {
    out = handleAiSuggest(p);

  } else {
    out = { error: "unknown action" };
  }

  return ContentService
    .createTextOutput(JSON.stringify(out))
    .setMimeType(ContentService.MimeType.JSON);
}

function getSheet(name) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName(name);
  if (!sheet) {
    sheet = ss.insertSheet(name);
    sheet.appendRow(COLUMNS);
  }
  return sheet;
}

/**
 * Moves any Pipeline row in "Declined" stage whose StageChangedAt is older
 * than ARCHIVE_AFTER_DAYS into the Archive tab, then deletes it from Pipeline.
 * Runs automatically on "read" and can also be called manually or on a
 * time-driven trigger (Triggers > Add Trigger > archive_sweep > time-driven, daily).
 */
function runArchiveSweep() {
  var pipeline = getSheet(PIPELINE_SHEET_NAME);
  var archive = getSheet(ARCHIVE_SHEET_NAME);
  var data = pipeline.getDataRange().getValues();
  var stageCol = COLUMNS.indexOf("Stage");
  var stageChangedCol = COLUMNS.indexOf("StageChangedAt");
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - ARCHIVE_AFTER_DAYS);

  var rowsToDelete = [];
  var movedCount = 0;

  for (var i = 1; i < data.length; i++) { // skip header row
    var row = data[i];
    if (row[stageCol] === DECLINED_STAGE) {
      var changedAt = new Date(row[stageChangedCol]);
      if (!isNaN(changedAt.getTime()) && changedAt < cutoff) {
        archive.appendRow(row);
        rowsToDelete.push(i + 1); // +1 for 1-indexed sheet rows
        movedCount++;
      }
    }
  }

  // Delete from bottom up so row numbers don't shift mid-loop
  for (var j = rowsToDelete.length - 1; j >= 0; j--) {
    pipeline.deleteRow(rowsToDelete[j]);
  }

  return movedCount;
}

// OpenAI model used for AI suggestions. Verify this is still current at
// platform.openai.com/docs/models before relying on it long-term; model
// names are updated by OpenAI independent of this script.
var OPENAI_MODEL = "gpt-4o";

/**
 * Three AI features, selected by p.type:
 *   "stage_guidance" — suggest the next move given stage, notes, last contact
 *   "draft_outreach" — draft a partnership pitch or funding ask from notes
 *   "fit_score"      — score how well a prospect matches the org's mission/ask
 *
 * Provider is selected by p.apiProvider: "anthropic" (default) or "openai".
 * p.apiKey should hold the key for whichever provider is selected.
 */
function handleAiSuggest(p) {
  var apiKey = p.apiKey;
  var type = p.type;
  var provider = p.apiProvider || "anthropic";

  if (!apiKey || !type) {
    return { error: "Missing apiKey or type" };
  }

  var prompt = buildAiPrompt(type, p);
  if (!prompt) {
    return { error: "Unknown AI suggestion type" };
  }

  if (provider === "openai") {
    return callOpenAi(apiKey, prompt, type);
  } else if (provider === "anthropic") {
    return callAnthropic(apiKey, prompt, type);
  } else {
    return { error: "Unknown apiProvider: " + provider };
  }
}

function callAnthropic(apiKey, prompt, type) {
  var payload = JSON.stringify({
    model: "claude-sonnet-5",
    max_tokens: 8000,
    messages: [{ role: "user", content: prompt }]
  });

  var options = {
    method: "post",
    contentType: "application/json",
    headers: {
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01"
    },
    payload: payload,
    muteHttpExceptions: true
  };

  try {
    var resp = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", options);
    var result = JSON.parse(resp.getContentText());
    if (result.content && result.content[0]) {
      return { suggestion: result.content[0].text, type: type, provider: "anthropic" };
    } else {
      return { error: result.error ? result.error.message : "No response from AI" };
    }
  } catch (err) {
    return { error: err.message };
  }
}

function callOpenAi(apiKey, prompt, type) {
  var payload = JSON.stringify({
    model: OPENAI_MODEL,
    max_tokens: 8000,
    messages: [{ role: "user", content: prompt }]
  });

  var options = {
    method: "post",
    contentType: "application/json",
    headers: {
      "Authorization": "Bearer " + apiKey
    },
    payload: payload,
    muteHttpExceptions: true
  };

  try {
    var resp = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", options);
    var result = JSON.parse(resp.getContentText());
    if (result.choices && result.choices[0] && result.choices[0].message) {
      return { suggestion: result.choices[0].message.content, type: type, provider: "openai" };
    } else {
      return { error: result.error ? result.error.message : "No response from AI" };
    }
  } catch (err) {
    return { error: err.message };
  }
}

function buildAiPrompt(type, p) {
  var orgName = p.orgName || "this prospect";
  var stage = p.stage || "unknown stage";
  var notes = p.notes || "no notes on file";
  var lastContact = p.lastContactDate || "unknown";
  var askType = p.askType || "unspecified";

  if (type === "stage_guidance") {
    return "You are helping a nonprofit/partnerships professional manage a donor or partner pipeline. " +
      "Prospect: " + orgName + ". Current stage: " + stage + ". Last contact: " + lastContact + ". " +
      "Notes: " + notes + ". " +
      "Suggest one specific, concrete next action to move this relationship forward, tailored to their current stage. " +
      "Keep it to 2-3 sentences, no preamble.";

  } else if (type === "draft_outreach") {
    return "You are drafting outreach for a nonprofit/partnerships professional. " +
      "Prospect: " + orgName + ". Ask type: " + askType + ". Notes on the relationship: " + notes + ". " +
      "Draft a short, warm, specific outreach message (email length, not a letter) that references what's " +
      "actually in the notes rather than generic language. No em dashes. No preamble, just the draft.";

  } else if (type === "fit_score") {
    return "You are evaluating donor/partner fit for a nonprofit or organization. " +
      "Prospect: " + orgName + ". Notes: " + notes + ". " +
      "Based only on the notes provided, give a fit score from 1-5 and a one-sentence rationale. " +
      "If the notes don't contain enough information to judge fit, say so plainly instead of guessing. " +
      "Format: 'Score: X/5 — rationale.'";
  }

  return null;
}
  • 4
    Press Cmd+S to save. The file name should show as Code.gs.
3
Step three

Deploy as a Web App

This step creates the secure URL that connects Partner Momentum to your Google Sheet.

You'll see a security warning during authorization. This is normal and expected, the script only accesses your own Google Sheet.
  • 1
    In the Apps Script editor, click the blue Deploy button (top right). Select New deployment.
  • 2
    Click the gear icon next to "Select type" and choose Web app. Set Execute as: Me, and Who has access: Anyone.
  • 3
    Click Deploy, then Authorize access. Sign in with your Google account. If you see "Google hasn't verified this app," click Advanced, then Go to [project name] (unsafe), then Allow.
  • 4
    Copy the Web App URL shown, it looks like https://script.google.com/macros/s/XXXXXXXXXX/exec. You'll need it in the next step.
4
Step four

Connect the app

  • 1
    Go to partnermomentum.app and click Launch the app.
  • 2
    Paste your Web App URL into the "Google Apps Script Web App URL" field.
  • 3
    Choose Anthropic or OpenAI as your AI provider, and paste your API key. You can skip this and add it later in Settings.
  • 4
    Click Connect my sheet. If everything worked, you'll see your empty pipeline board. You're ready to add your first prospect.
"Could not connect" error? Make sure you copied the full Web App URL including /exec at the end, and that deployment settings were Execute as Me, Who has access Anyone. Try redeploying: Deploy > Manage deployments > pencil icon > New version > Deploy.

Need help? rebeca@plainspeakingcomms.com

Ready to build momentum?

You're all set. Open the app and add your first prospect.

Launch Partner Momentum →