Build an AI Hiring Inbox Router

Route synthetic CVs with Gemini, Gmail, and human approval gates.

Introduction

30 Second Summary

A busy hiring inbox can scatter application details across attachments and replies. A rushed shortcut can also hide why a recommendation was made.

In this project, you will build a synthetic-only hiring inbox router with Gmail, Google Sheets, Google Apps Script, and the Gemini API. It turns fictional PDF CVs into evidence-backed review rows plus editable drafts that stay unsent until you approve them.

What You'll Build

Picture choosing a command from your Google Sheet and watching a fictional application become a scored review row with an editable Gmail draft waiting for your decision.

By the end of this project, you'll have:

  • Transparent CV scoring that lets you compare six job-related scores with the evidence, gaps, and uncertainty behind the recommendation.
  • Human-controlled responses where you can edit either outcome, override the AI recommendation, and approve the matching draft before it can be sent.
  • Resumable batch processing that routes small groups, blocks duplicates, surfaces retries, and simulates a 200-item burst without mass email or API calls.
  • Secret Mission: Create a deliberately tricky fictional CV, catch a bad recommendation, and document the corrected human decision.

Are there any prerequisites?

You need a Gmail or Google account plus basic coding experience. No local installation is required.

The free Gemini API tier uses submitted content to improve Google products. Every email, CV, alias, and recipient in this project must remain fictional.

Before We Start

This first checkpoint commits you to a synthetic-only Junior Software Engineer hiring-router demonstration. Every email, CV, alias, attachment, and recipient stays fictional because the free Gemini API tier is unsuitable for real applicant data.

Set Up a Synthetic-Only Workspace Sandbox

Hiring workflows can expose sensitive information as soon as a message enters the inbox. Your safety commitment needs an isolated path before any automation starts.

This sandbox uses Gemini to supply fictional source material. Google Workspace keeps every project artifact inside a browser-only test path.

In this step, get ready to:
  • Prepare three fictional CV PDFs with Google Docs.
  • Build a labeled synthetic inbox in Gmail.
  • Create a Google Sheets file with a bound Google Apps Script project.
Create fictional CV PDFs

A useful test CV contains realistic job evidence without describing a real person. Three contrasting profiles give the router different scoring inputs later.

Keep Every Detail Fictional

  • Each CV uses an alias such as Synthetic Candidate 001.
  • No real contact details appear.
  • No real employers or schools appear.
  • No real addresses or identifying information appear.
  • Job-related evidence covers skills such as JavaScript or Git.
  • Sign in to Gemini with the Google account reserved for this synthetic project.
  • Ask Gemini to generate three contrasting fictional Junior Software Engineer CVs using aliases such as Synthetic Candidate 001.

You should see three distinct fictional profiles. Their skill levels should provide different inputs for the scoring workflow.

  • Review each generated CV against the synthetic rules above.
  • Confirm that each CV includes job-related evidence for the fictional role.

That is your first safety boundary in place. The source material now has useful variety without representing a real person.

  • Sign in to Google Docs with the same Google account.
  • Click Create on the Google Docs home screen.
  • Paste the first fictional CV into the blank document.
  • Click the file name at the top of the document.
  • Enter Synthetic Candidate 001 CV as the document name.
  • Press Enter to save the name.

The first fictional CV now exists as a Google Doc under its synthetic alias.

  • Repeat the document creation process for Synthetic Candidate 002 CV.
  • Repeat the document creation process for Synthetic Candidate 003 CV.
  • Return to the first fictional CV document.
  • Click File in the top menu.
  • Select Download.
  • Select PDF Document (.pdf).
  • Repeat the PDF export for the other two fictional CV documents.

You should see three fictional CV files in your browser downloads. Each file name should match its synthetic alias.

Missing a PDF or Seeing Real Details?

  • Return to the matching Google Doc to confirm that its synthetic alias appears in the file name.
  • Remove any detail that could identify a real person before exporting the document again.
  • Repeat the download through File and Download if the PDF is missing.

Ask for help with checking whether my fictional CVs are safe to use.

Build the labeled test inbox

The router later requires two visible safety markers before a PDF can reach Gemini. The subject marker identifies the thread while the body marker confirms its test-only purpose.

  • Sign in to Gmail with the same Google account.
  • Click Compose in the top left.
  • Enter your own Gmail address in the To field.
  • Enter [SYNTHETIC] Synthetic Candidate 001 in the Subject field.
  • Enter SYNTHETIC TEST DATA in the message body.
  • Click Attach files at the bottom of the compose window.
  • Select the Synthetic Candidate 001 PDF.
  • Click Open in the file picker.

The compose window should show the fictional PDF as an attachment. The two synthetic markers should remain visible.

  • Click Send at the bottom of the message.
  • Repeat the compose process for [SYNTHETIC] Synthetic Candidate 002 with its matching PDF.
  • Repeat the compose process for [SYNTHETIC] Synthetic Candidate 003 with its matching PDF.

Your inbox should receive three self-sent fictional applications. Each one is now a safe input for the later router checks.

  • Click Create new label beside Labels in the Gmail sidebar.
  • Enter synthetic-applications as the label name.
  • Click Create.
  • Select the checkboxes beside all three self-sent application threads.
  • Click Labels in the inbox toolbar.
  • Select synthetic-applications from the label menu.

The synthetic-applications label should now appear on all three threads. This label becomes the router's controlled intake queue.

Missing an Email or Label?

  • Check the Sent label to confirm that all three messages left your account.
  • Check that every message was addressed to your own Gmail address.
  • Select the three threads again before applying synthetic-applications.

Ask for help with finding my self-sent Gmail messages or applying the synthetic label.

Bind the sheet and store the key

The spreadsheet becomes the review workspace in the next step. A bound Apps Script project lets the sheet run the router without a local installation.

  • Sign in to Google Sheets with the same Google account.
  • Click New on the Google Sheets home screen.
  • Click Untitled spreadsheet at the top of the blank sheet.
  • Enter Synthetic Hiring Router as the spreadsheet name.
  • Press Enter to save the name.
  • Click Extensions in the spreadsheet menu.
  • Select Apps Script.

You should see the Apps Script editor bound to Synthetic Hiring Router. The spreadsheet itself remains blank.

The Gemini API key grants this script access to the model later. The free tier can use submitted content to improve Google products, which keeps this workspace limited to fictional PDFs.

What Happens on Your First Visit?

New Google AI Studio users may see a terms prompt before key management becomes available. AI Studio may also provide a default project after those terms are accepted.

The API key functions like a password. Its only destination in this project is Apps Script Script Properties.

  • Open Google AI Studio in another browser tab.
  • Sign in with the same Google account.
  • Review the terms prompt if it appears.
  • Accept the terms after reviewing them.
  • Open Dashboard.
  • Select Projects.
  • Select API Keys.
  • Create a Gemini API key for your AI Studio project.

AI Studio should reveal the new key once. This is the sensitive handoff point between AI Studio and your bound script.

  • Copy the new API key.
  • Return to the Apps Script editor from earlier.
  • Select Project Settings in the left sidebar.
  • Find Script Properties.
  • Select Add script property.
  • Enter GEMINI_API_KEY as the property name.
  • Paste the copied key into the property value field.
  • Select Save script properties.

The Script Properties section should now list GEMINI_API_KEY. Your credential is stored outside the project code.

Property Not Staying Saved?

  • Confirm that the property name is exactly GEMINI_API_KEY.
  • Confirm that the property value field contains the copied key.
  • Select Save script properties again after completing both fields.

Ask for help with storing my Gemini API key in Apps Script without exposing it.

Before the final check, how many clearly marked fictional application threads do you expect the Gmail label to contain?

  • Return to Gmail.
  • Select synthetic-applications in the left sidebar.
  • Open each of the three labeled application threads.

You should see at least three threads sent to yourself. Every subject contains [SYNTHETIC].

Every body contains SYNTHETIC TEST DATA. Every message has one fictional PDF CV attached.

✔️ My sandbox is ready

Your browser-only sandbox is ready. Every labeled application now has the markers and fictional attachment that protect the next scoring step.

ⓧ I'd like to double check the final state

  • Confirm that at least three fictional Junior Software Engineer CV PDFs were created from Google Docs.
  • Confirm that no CV contains real contact details or identifying information.
  • Confirm that synthetic-applications contains at least three self-sent fictional application threads.
  • Confirm that every labeled subject contains [SYNTHETIC].
  • Confirm that every labeled body contains SYNTHETIC TEST DATA.
  • Confirm that every labeled message has one fictional PDF CV attachment.
  • Confirm that the blank spreadsheet is named Synthetic Hiring Router.
  • Confirm that the spreadsheet has a bound Apps Script project.
  • Confirm that Script Properties lists GEMINI_API_KEY.

Your isolated intake path is ready. Next, you will score one fictional PDF into a visible review row while keeping every email action unavailable.

Score a Synthetic CV with Gemini

Your fictional applications now have a protected route into a browser-only workspace. In this step, Google Apps Script connects one synthetic PDF to a visible review queue.

A raw score from Gemini would hide the reasoning that a reviewer needs. You will expose category scores, evidence, gaps, and uncertainty while keeping every email action unavailable.

In this step, get ready to:
  • Create the router configuration and sheet setup.
  • Add structured synthetic PDF analysis with bounded scores.
  • Score one fictional CV without creating a draft.
Build the router foundation

The configuration file gives Google Sheets one exact set of queue columns, settings, and workflow labels. The setup file turns those definitions into visible tabs and Gmail labels.

  • Switch back to the bound Apps Script editor from the previous step.
  • Select the plus icon beside the Files pane.
  • Select Script.
  • Enter Config as the script name.
  • Use the second tab below to replace the contents of Config.gs with the complete configuration source.

✔️ Awesome, I've got everything!

Your Config.gs file now contains the shared model, label, sheet, column, and helper definitions.

ⓧ I'd like to double check the full code

const CONFIG = Object.freeze({
  MODEL: 'gemini-3.8-flash',
  ENDPOINT: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent',
  SOURCE_LABEL: 'synthetic-applications',
  PROCESSED_LABEL: 'router-processed',
  QUARANTINE_LABEL: 'router-quarantine',
  QUEUE_SHEET: 'Review Queue',
  SETTINGS_SHEET: 'Settings',
  BURST_SHEET: 'Burst Test',
  API_KEY_PROPERTY: 'GEMINI_API_KEY',
  SPREADSHEET_ID_PROPERTY: 'SPREADSHEET_ID',
  TRIGGER_HANDLER: 'processSyntheticApplications',
});

const HEADERS = Object.freeze([
  'Received',
  'Message ID',
  'Synthetic Candidate',
  'PDF',
  'JavaScript /25',
  'Web /20',
  'Git /15',
  'Testing /15',
  'Projects /15',
  'Communication /10',
  'Total /100',
  'AI Recommendation',
  'Evidence',
  'Gaps',
  'Uncertainty',
  'Uncertainty Reason',
  'Final Decision',
  'Reviewer Notes',
  'Review Status',
  'Draft Decision',
  'Draft ID',
  'Workflow Status',
  'Sent At',
  'Error',
]);

const COL = Object.freeze({
  RECEIVED: 1,
  MESSAGE_ID: 2,
  CANDIDATE: 3,
  PDF: 4,
  JAVASCRIPT: 5,
  WEB: 6,
  GIT: 7,
  TESTING: 8,
  PROJECTS: 9,
  COMMUNICATION: 10,
  TOTAL: 11,
  AI_RECOMMENDATION: 12,
  EVIDENCE: 13,
  GAPS: 14,
  UNCERTAINTY: 15,
  UNCERTAINTY_REASON: 16,
  FINAL_DECISION: 17,
  REVIEWER_NOTES: 18,
  REVIEW_STATUS: 19,
  DRAFT_DECISION: 20,
  DRAFT_ID: 21,
  WORKFLOW_STATUS: 22,
  SENT_AT: 23,
  ERROR: 24,
});

function getSpreadsheet_() {
  const properties = PropertiesService.getScriptProperties();
  const id = properties.getProperty(CONFIG.SPREADSHEET_ID_PROPERTY);
  const spreadsheet = id
    ? SpreadsheetApp.openById(id)
    : SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('No spreadsheet is available. Run setupRouter from the bound sheet.');
  }
  return spreadsheet;
}

function getQueueSheet_() {
  const sheet = getSpreadsheet_().getSheetByName(CONFIG.QUEUE_SHEET);
  if (!sheet) {
    throw new Error('Review Queue is missing. Run setupRouter.');
  }
  return sheet;
}

function getSetting_(name) {
  const sheet = getSpreadsheet_().getSheetByName(CONFIG.SETTINGS_SHEET);
  if (!sheet || sheet.getLastRow() < 2) {
    throw new Error('Settings are missing. Run setupRouter.');
  }
  const values = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
  for (let i = 0; i < values.length; i++) {
    if (String(values[i][0]) === name) {
      return values[i][1];
    }
  }
  throw new Error(`Missing setting: ${name}`);
}

function ensureLabel_(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

function toast_(message) {
  getSpreadsheet_().toast(message, 'Synthetic Hiring Router', 6);
}

What does this file define?

  • The CONFIG object keeps the model, endpoint, sheet names, property names, and workflow labels consistent.
  • The HEADERS array fixes the order of every review field that appears in the queue.
  • The COL object lets later functions update fields by a named column position.
  • The helper functions retrieve the bound spreadsheet, read settings, create missing labels, and show status messages.
  • Select the plus icon beside the Files pane.
  • Select Script.
  • Enter Setup as the script name.
  • Use the second tab below to replace the contents of Setup.gs with the complete setup source.

✔️ Awesome, I've got everything!

Your Setup.gs file now defines the menu, initial router setup, automation controls, and cleanup helper.

ⓧ I'd like to double check the full code

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Hiring Router')
    .addItem('Set up router', 'setupRouter')
    .addItem('Score first synthetic application', 'scoreFirstSyntheticApplication')
    .addItem('Prepare selected draft', 'prepareSelectedDraft')
    .addItem('Rebuild selected draft', 'rebuildSelectedDraft')
    .addItem('Send approved drafts', 'sendApprovedDrafts')
    .addSeparator()
    .addItem('Process one batch', 'processSyntheticApplications')
    .addItem('Install five-minute automation', 'installBatchAutomation')
    .addItem('Remove automation', 'removeBatchAutomation')
    .addItem('Retry selected application', 'retrySelectedApplication')
    .addSeparator()
    .addItem('Seed 200-item burst test', 'seedSyntheticBurstTest')
    .addItem('Run troubleshooting checks', 'runTroubleshootingChecks')
    .addItem('Clean up synthetic project', 'cleanupSyntheticProject')
    .addToUi();
}

function setupRouter() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) {
    throw new Error('Open the bound Google Sheet before running setupRouter.');
  }

  const properties = PropertiesService.getScriptProperties();
  properties.setProperty(CONFIG.SPREADSHEET_ID_PROPERTY, spreadsheet.getId());
  if (!properties.getProperty(CONFIG.API_KEY_PROPERTY)) {
    throw new Error('Add GEMINI_API_KEY under Project Settings > Script Properties first.');
  }

  let queue = spreadsheet.getSheetByName(CONFIG.QUEUE_SHEET);
  if (!queue) {
    queue = spreadsheet.insertSheet(CONFIG.QUEUE_SHEET);
  }
  if (queue.getLastRow() === 0) {
    queue.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
  }
  queue.setFrozenRows(1);
  queue.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
  queue.getRange(2, 1, 999, HEADERS.length).setWrap(true);

  const decisionRule = SpreadsheetApp.newDataValidation()
    .requireValueInList(['PROCEED', 'DO_NOT_PROCEED'])
    .setAllowInvalid(false)
    .build();
  queue.getRange(2, COL.FINAL_DECISION, 999, 1).setDataValidation(decisionRule);

  const approvalRule = SpreadsheetApp.newDataValidation()
    .requireValueInList(['PENDING', 'APPROVED', 'SENT'])
    .setAllowInvalid(false)
    .build();
  queue.getRange(2, COL.REVIEW_STATUS, 999, 1).setDataValidation(approvalRule);

  let settings = spreadsheet.getSheetByName(CONFIG.SETTINGS_SHEET);
  if (!settings) {
    settings = spreadsheet.insertSheet(CONFIG.SETTINGS_SHEET);
  }
  if (settings.getLastRow() === 0) {
    settings.getRange(1, 1, 8, 2).setValues([
      ['Setting', 'Value'],
      ['ROLE', 'Junior Software Engineer'],
      ['THRESHOLD', 70],
      ['BATCH_SIZE', 5],
      ['INTERVIEW_DATE', '2030-06-15'],
      ['INTERVIEW_TIME', '10:00'],
      ['INTERVIEW_LOCATION', 'Synthetic Office, Meeting Room A'],
      ['SYNTHETIC_ONLY', 'TRUE'],
    ]);
    settings.getRange(1, 1, 1, 2).setFontWeight('bold');
    settings.setFrozenRows(1);
  }

  ensureLabel_(CONFIG.SOURCE_LABEL);
  ensureLabel_(CONFIG.PROCESSED_LABEL);
  ensureLabel_(CONFIG.QUARANTINE_LABEL);
  onOpen();
  toast_('Setup complete. Add only clearly marked synthetic test emails.');
}

function installBatchAutomation() {
  setupRouter();
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === CONFIG.TRIGGER_HANDLER) {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
  ScriptApp.newTrigger(CONFIG.TRIGGER_HANDLER)
    .timeBased()
    .everyMinutes(5)
    .create();
  toast_('Five-minute batch automation installed. Drafts still require approval.');
}

function removeBatchAutomation() {
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === CONFIG.TRIGGER_HANDLER) {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
  toast_('Batch automation removed.');
}

function cleanupSyntheticProject() {
  removeBatchAutomation();
  const queue = getQueueSheet_();
  if (queue.getLastRow() > 1) {
    const draftIds = queue
      .getRange(2, COL.DRAFT_ID, queue.getLastRow() - 1, 1)
      .getValues();
    for (let i = 0; i < draftIds.length; i++) {
      const draftId = String(draftIds[i][0] || '');
      if (!draftId) continue;
      try {
        GmailApp.getDraft(draftId).deleteDraft();
      } catch (error) {
        console.log(`Draft cleanup skipped: ${error.message}`);
      }
    }
  }

  [CONFIG.SOURCE_LABEL, CONFIG.PROCESSED_LABEL, CONFIG.QUARANTINE_LABEL]
    .forEach(function(name) {
      const label = GmailApp.getUserLabelByName(name);
      if (label) label.deleteLabel();
    });

  queue.clear();
  const settings = getSpreadsheet_().getSheetByName(CONFIG.SETTINGS_SHEET);
  if (settings) settings.clear();
  const burst = getSpreadsheet_().getSheetByName(CONFIG.BURST_SHEET);
  if (burst) burst.clear();

  const properties = PropertiesService.getScriptProperties();
  properties.deleteProperty(CONFIG.API_KEY_PROPERTY);
  properties.deleteProperty(CONFIG.SPREADSHEET_ID_PROPERTY);
  SpreadsheetApp.flush();
}

What does this file prepare?

  • The onOpen() function adds the Hiring Router menu to the spreadsheet.
  • The setupRouter() function records the spreadsheet ID before creating the queue, settings, dropdowns, and Gmail labels.
  • The remaining functions provide later controls for recurring processing and synthetic project cleanup.

The first run can request access to the Google services used by the router. This authorization lets the bound script create sheet tabs and manage its synthetic workflow labels.

  • Select setupRouter in the Apps Script function selector.
  • Select Run.
  • Complete the Google authorization prompts if they appear.
  • Return to the Apps Script editor after authorization finishes.

You will see a setup-complete message in the spreadsheet. The script now has a stored spreadsheet ID plus the queue structure it needs for scoring.

  • Switch back to the Synthetic Hiring Router spreadsheet.
  • Confirm the Review Queue tab is visible.
  • Confirm the Settings tab is visible.
  • Confirm the Hiring Router menu is visible.
  • Inspect the Settings tab for the junior software engineer role and the 70 threshold.

Good progress. Your blank spreadsheet is now a controlled review workspace with decision fields and workflow labels.

Setup did not finish?

Confirm that Config.gs and Setup.gs are saved in the bound Apps Script project. A missing GEMINI_API_KEY Script Property also stops setup before the tabs are created.

Run setupRouter from the spreadsheet-bound project after correcting the missing item.

Help me diagnose why setupRouter did not create the Review Queue and Settings tabs in my bound Google Apps Script project.

Add structured PDF analysis

Structured JSON gives the workflow predictable fields for each rubric category. The code still validates the final values because a schema cannot prove that a model claim is supported by the CV.

The scorer checks both synthetic markers before any attachment reaches gemini-3.8-flash. It calculates the final total from bounded category scores instead of trusting a model-generated total.

  • Switch back to the bound Apps Script editor.
  • Select the plus icon beside the Files pane.
  • Select Script.
  • Enter Gemini as the script name.
  • Use the second tab below to replace the contents of Gemini.gs with the complete scoring source.

✔️ Awesome, I've got everything!

Your Gemini.gs file now validates synthetic inputs, analyzes one PDF, normalizes the scores, and writes the review row.

ⓧ I'd like to double check the full code

const ANALYSIS_SCHEMA = Object.freeze({
  type: 'object',
  properties: {
    javascript_score: {
      type: 'integer',
      minimum: 0,
      maximum: 25,
      description: 'Evidence-based JavaScript fundamentals score from 0 to 25.',
    },
    web_score: {
      type: 'integer',
      minimum: 0,
      maximum: 20,
      description: 'Evidence-based HTML, CSS, HTTP, or web fundamentals score from 0 to 20.',
    },
    git_score: {
      type: 'integer',
      minimum: 0,
      maximum: 15,
      description: 'Evidence-based Git or source control score from 0 to 15.',
    },
    testing_score: {
      type: 'integer',
      minimum: 0,
      maximum: 15,
      description: 'Evidence-based testing and debugging score from 0 to 15.',
    },
    projects_score: {
      type: 'integer',
      minimum: 0,
      maximum: 15,
      description: 'Evidence-based software project delivery score from 0 to 15.',
    },
    communication_score: {
      type: 'integer',
      minimum: 0,
      maximum: 10,
      description: 'Evidence-based technical communication score from 0 to 10.',
    },
    evidence: {
      type: 'array',
      maxItems: 5,
      items: {type: 'string'},
      description: 'Short job-related claims supported directly by the CV.',
    },
    gaps: {
      type: 'array',
      maxItems: 4,
      items: {type: 'string'},
      description: 'Missing or weak job-related evidence, phrased constructively.',
    },
    uncertainty: {
      type: 'boolean',
      description: 'True when the document is ambiguous, incomplete, or hard to read.',
    },
    uncertainty_reason: {
      type: 'string',
      description: 'A concise explanation of uncertainty, or an empty string.',
    },
  },
  required: [
    'javascript_score',
    'web_score',
    'git_score',
    'testing_score',
    'projects_score',
    'communication_score',
    'evidence',
    'gaps',
    'uncertainty',
    'uncertainty_reason',
  ],
  additionalProperties: false,
});

function scoreFirstSyntheticApplication() {
  setupRouter();
  const source = ensureLabel_(CONFIG.SOURCE_LABEL);
  const threads = source.getThreads(0, 1);
  if (threads.length === 0) {
    throw new Error('No email has the synthetic-applications label.');
  }
  const item = getSyntheticItemFromThread_(threads[0]);
  assertSyntheticMessage_(item.message);
  const analysis = analyzePdf_(item.pdf);
  const row = writeAnalysisRow_(item.message, item.pdf, analysis, 'SCORED_ONLY');
  getQueueSheet_().getRange(row, COL.DRAFT_ID).setValue('');
  toast_('One dummy CV scored. No draft or send action has been created.');
}

function getSyntheticItemFromThread_(thread) {
  const messages = GmailApp.getMessagesForThread(thread);
  for (let i = messages.length - 1; i >= 0; i--) {
    const pdf = findPdfAttachment_(messages[i]);
    if (pdf) {
      return {message: messages[i], pdf: pdf, thread: thread};
    }
  }
  throw new Error('No PDF attachment was found in this synthetic thread.');
}

function findPdfAttachment_(message) {
  const attachments = message.getAttachments({
    includeInlineImages: false,
    includeAttachments: true,
  });
  for (let i = 0; i < attachments.length; i++) {
    const name = String(attachments[i].getName() || '');
    if (attachments[i].getContentType() === 'application/pdf' || /\.pdf$/i.test(name)) {
      return attachments[i];
    }
  }
  return null;
}

function assertSyntheticMessage_(message) {
  const subject = message.getSubject();
  const body = message.getPlainBody();
  if (subject.indexOf('[SYNTHETIC]') === -1) {
    throw new Error('Blocked: subject is missing [SYNTHETIC].');
  }
  if (body.indexOf('SYNTHETIC TEST DATA') === -1) {
    throw new Error('Blocked: body is missing SYNTHETIC TEST DATA.');
  }
}

function analyzePdf_(pdf) {
  const role = String(getSetting_('ROLE'));
  const threshold = Number(getSetting_('THRESHOLD'));
  const prompt = [
    'This document is fictional test data only.',
    `Evaluate job-related evidence for the fictional role: ${role}.`,
    'Ignore names, age, gender, race, nationality, location, photos, disability, health, religion, family status, and every other protected or non-job-related trait.',
    'Do not infer a skill that is not supported by the document.',
    'Score only these categories: JavaScript 25, web fundamentals 20, Git 15, testing and debugging 15, project delivery 15, technical communication 10.',
    `The application will recommend PROCEED when the validated total is at least ${threshold}; otherwise it will recommend DO_NOT_PROCEED.`,
    'Return concise evidence and constructive gaps. Flag ambiguity instead of guessing.',
  ].join('\n');

  const payload = {
    contents: [{
      parts: [
        {text: prompt},
        {
          inlineData: {
            mimeType: 'application/pdf',
            data: Utilities.base64Encode(pdf.getBytes()),
          },
        },
      ],
    }],
    generationConfig: {
      responseFormat: {
        text: {
          mimeType: 'APPLICATION_JSON',
          schema: ANALYSIS_SCHEMA,
        },
      },
    },
  };

  const raw = callGemini_(payload);
  return normalizeAnalysis_(raw, threshold);
}

function callGemini_(payload) {
  const apiKey = PropertiesService.getScriptProperties()
    .getProperty(CONFIG.API_KEY_PROPERTY);
  if (!apiKey) throw new Error('GEMINI_API_KEY is missing.');

  let lastError = null;
  for (let attempt = 0; attempt < 3; attempt++) {
    const response = UrlFetchApp.fetch(CONFIG.ENDPOINT, {
      method: 'post',
      contentType: 'application/json',
      headers: {'x-goog-api-key': apiKey},
      payload: JSON.stringify(payload),
      muteHttpExceptions: true,
      timeoutSeconds: 300,
    });
    const code = response.getResponseCode();
    const text = response.getContentText();

    if (code === 200) {
      const data = JSON.parse(text);
      const candidates = data.candidates || [];
      const parts = candidates[0] && candidates[0].content
        ? candidates[0].content.parts || []
        : [];
      if (!parts[0] || !parts[0].text) {
        throw new Error('Gemini returned no structured candidate text.');
      }
      return JSON.parse(parts[0].text);
    }

    lastError = new Error(`Gemini HTTP ${code}: ${text.substring(0, 300)}`);
    const retryable = code === 408 || code === 429 || code >= 500;
    if (!retryable || attempt === 2) break;
    const delay = Math.pow(2, attempt) * 1000 + Math.floor(Math.random() * 1000);
    Utilities.sleep(delay);
  }
  throw lastError;
}

function normalizeAnalysis_(raw, threshold) {
  const analysis = {
    javascript: clamp_(raw.javascript_score, 0, 25),
    web: clamp_(raw.web_score, 0, 20),
    git: clamp_(raw.git_score, 0, 15),
    testing: clamp_(raw.testing_score, 0, 15),
    projects: clamp_(raw.projects_score, 0, 15),
    communication: clamp_(raw.communication_score, 0, 10),
    evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],
    gaps: Array.isArray(raw.gaps) ? raw.gaps.slice(0, 4) : [],
    uncertainty: Boolean(raw.uncertainty),
    uncertaintyReason: String(raw.uncertainty_reason || ''),
  };
  analysis.total = analysis.javascript + analysis.web + analysis.git +
    analysis.testing + analysis.projects + analysis.communication;
  analysis.recommendation = analysis.total >= threshold
    ? 'PROCEED'
    : 'DO_NOT_PROCEED';
  return analysis;
}

function clamp_(value, minimum, maximum) {
  const number = Math.round(Number(value));
  if (!Number.isFinite(number)) return minimum;
  return Math.max(minimum, Math.min(maximum, number));
}

function writeAnalysisRow_(message, pdf, analysis, workflowStatus) {
  const queue = getQueueSheet_();
  let row = findRowByMessageId_(message.getId());
  const existing = row > 0
    ? queue.getRange(row, 1, 1, HEADERS.length).getValues()[0]
    : new Array(HEADERS.length).fill('');

  if (row < 0) row = queue.getLastRow() + 1;
  const finalDecision = existing[COL.FINAL_DECISION - 1] || analysis.recommendation;
  const values = new Array(HEADERS.length).fill('');
  values[COL.RECEIVED - 1] = message.getDate();
  values[COL.MESSAGE_ID - 1] = message.getId();
  values[COL.CANDIDATE - 1] = syntheticAliasFromSubject_(message.getSubject());
  values[COL.PDF - 1] = pdf.getName();
  values[COL.JAVASCRIPT - 1] = analysis.javascript;
  values[COL.WEB - 1] = analysis.web;
  values[COL.GIT - 1] = analysis.git;
  values[COL.TESTING - 1] = analysis.testing;
  values[COL.PROJECTS - 1] = analysis.projects;
  values[COL.COMMUNICATION - 1] = analysis.communication;
  values[COL.TOTAL - 1] = analysis.total;
  values[COL.AI_RECOMMENDATION - 1] = analysis.recommendation;
  values[COL.EVIDENCE - 1] = analysis.evidence.join('\n');
  values[COL.GAPS - 1] = analysis.gaps.join('\n');
  values[COL.UNCERTAINTY - 1] = analysis.uncertainty ? 'YES' : 'NO';
  values[COL.UNCERTAINTY_REASON - 1] = analysis.uncertaintyReason;
  values[COL.FINAL_DECISION - 1] = finalDecision;
  values[COL.REVIEWER_NOTES - 1] = existing[COL.REVIEWER_NOTES - 1] || '';
  values[COL.REVIEW_STATUS - 1] = existing[COL.REVIEW_STATUS - 1] || 'PENDING';
  values[COL.DRAFT_DECISION - 1] = existing[COL.DRAFT_DECISION - 1] || '';
  values[COL.DRAFT_ID - 1] = existing[COL.DRAFT_ID - 1] || '';
  values[COL.WORKFLOW_STATUS - 1] = workflowStatus;
  values[COL.SENT_AT - 1] = existing[COL.SENT_AT - 1] || '';
  values[COL.ERROR - 1] = '';
  queue.getRange(row, 1, 1, HEADERS.length).setValues([values]);
  return row;
}

function findRowByMessageId_(messageId) {
  const queue = getQueueSheet_();
  if (queue.getLastRow() < 2) return -1;
  const ids = queue
    .getRange(2, COL.MESSAGE_ID, queue.getLastRow() - 1, 1)
    .getValues();
  for (let i = 0; i < ids.length; i++) {
    if (String(ids[i][0]) === String(messageId)) return i + 2;
  }
  return -1;
}

function syntheticAliasFromSubject_(subject) {
  return String(subject)
    .replace('[SYNTHETIC]', '')
    .replace(/^\s+|\s+$/g, '')
    .substring(0, 120) || 'Synthetic Candidate';
}

function recordError_(message, pdf, error) {
  const queue = getQueueSheet_();
  let row = findRowByMessageId_(message.getId());
  if (row < 0) {
    row = queue.getLastRow() + 1;
    const values = new Array(HEADERS.length).fill('');
    values[COL.RECEIVED - 1] = message.getDate();
    values[COL.MESSAGE_ID - 1] = message.getId();
    values[COL.CANDIDATE - 1] = syntheticAliasFromSubject_(message.getSubject());
    values[COL.PDF - 1] = pdf ? pdf.getName() : '';
    values[COL.REVIEW_STATUS - 1] = 'PENDING';
    queue.getRange(row, 1, 1, HEADERS.length).setValues([values]);
  }
  queue.getRange(row, COL.WORKFLOW_STATUS).setValue('ERROR');
  queue.getRange(row, COL.ERROR).setValue(String(error.message || error).substring(0, 500));
}

How does the scorer protect the review?

  • The schema requests six bounded job-related scores plus evidence, gaps, and uncertainty.
  • The marker check blocks a message unless its subject contains [SYNTHETIC] and its body contains SYNTHETIC TEST DATA.
  • The PDF is encoded for the model only after both synthetic markers pass.
  • The normalization functions clamp every score before calculating the final total and recommendation.
  • The queue writer leaves Draft ID blank when this scoring-only function runs.

Before you run the scorer, picture the queue row you expect one synthetic PDF to produce.

  • Select scoreFirstSyntheticApplication in the Apps Script function selector.
  • Select Run.
  • Complete any additional Google authorization prompts if they appear.
  • Wait for the execution to finish.

What happens during this run?

  • The function reads the first thread carrying the synthetic-applications label.
  • The function locates its fictional PDF attachment after checking both synthetic markers.
  • Gemini returns the structured category analysis for the fictional junior software engineer rubric.
  • The script calculates the total before writing a single queue row with SCORED_ONLY as its workflow status.

Scoring execution failed?

Confirm that one labeled thread still contains a PDF plus both required synthetic markers. Also confirm that the stored Script Property name is exactly GEMINI_API_KEY.

A temporary API failure can appear in the execution details after the built-in retry attempts finish. Run the scorer again after confirming the input and property.

Help me diagnose why scoreFirstSyntheticApplication failed without sharing my Gemini API key or any real personal data.

Inspect the evidence and safety gap

The row makes the recommendation inspectable. Each number now sits beside the evidence, missing signals, and uncertainty that a human reviewer needs.

  • Switch back to the Synthetic Hiring Router spreadsheet.
  • Select the Review Queue tab.
  • Inspect the six category score columns in the new row.
  • Inspect the Total /100 value.
  • Inspect the AI Recommendation value.
  • Compare the Evidence claims with the fictional PDF.
  • Review the Gaps and Uncertainty fields.
  • Confirm Review Status contains PENDING.

The scorer has produced an inspectable recommendation. The result remains a review input instead of an automatic action.

  • Confirm Workflow Status contains SCORED_ONLY.
  • Confirm Draft ID is blank.
  • Switch back to Gmail from earlier.
  • Check the drafts area for a new response linked to the scored synthetic application.

You will find no new response draft for this run. That visible limitation is intentional because the workflow has not added a human review gate yet.

Why stop at a recommendation?

A schema can keep the response shape predictable. It cannot guarantee that every claim is a fair reading of the fictional CV.

Keeping the draft fields empty forces the next stage to add an editable response plus an explicit human decision. No recommendation can contact anyone from this scoring step.

Your first synthetic CV now has a transparent score with no route to send an email. Next, you will place both possible outcomes behind an editable draft and human approval.

Add Human Review Gates

The Google Sheets queue now exposes the evidence behind one synthetic recommendation. The scored row still has no safe path for contacting its fictional candidate.

A model recommendation needs a human decision before it can shape a response. This step adds editable Gmail drafts with an approval gate that keeps every response unsent during review.

In this step, get ready to:
  • Create editable drafts for both fictional outcomes.
  • Record a human decision with reviewer notes.
  • Prove that a pending review cannot send.
Create the draft workflow

The bound Google Apps Script project needs menu-facing functions for preparing drafts. A shared helper keeps the first draft action consistent with every later rebuild.

  • Switch back to the bound Apps Script editor from earlier.
  • Create a script file from the file panel's add control.
  • Name the file Review so the project creates Review.gs.
  • Add the menu-facing draft functions to Review.gs by pasting this code:
function prepareSelectedDraft() {
  prepareSelectedDraft_();
}

function rebuildSelectedDraft() {
  prepareSelectedDraft_();
}

function prepareSelectedDraft_() {
  const queue = getQueueSheet_();
  const active = queue.getActiveRange();
  if (!active || active.getRow() < 2) {
    throw new Error('Select a data row in Review Queue first.');
  }
  prepareDraftForRow_(active.getRow());
  const messageId = queue.getRange(active.getRow(), COL.MESSAGE_ID).getValue();
  moveToProcessed_(GmailApp.getMessageById(String(messageId)));
  toast_('Draft prepared. Review it in Gmail before approval.');
}

What do these controls do?

  • The public functions give the custom menu separate preparation and rebuild actions.
  • The shared helper requires a selected data row from the Review Queue.
  • The helper moves the source message to the processed workflow after draft preparation.
  • Save Review.gs.
  • Confirm the editor accepts the new functions without showing a syntax warning.

Seeing a syntax warning?

Check that each public function has its own closing brace. Confirm that prepareSelectedDraft_ begins at the left edge of the file.

Help me check the draft control functions in Review.gs.

The row-level helper creates a new reply draft or updates the existing draft recorded in the queue. It resets the review state because every changed draft needs another human check.

  • Add the row-level draft helper below prepareSelectedDraft_ by pasting this code:
function prepareDraftForRow_(row) {
  const queue = getQueueSheet_();
  const values = queue.getRange(row, 1, 1, HEADERS.length).getValues()[0];
  const messageId = String(values[COL.MESSAGE_ID - 1] || '');
  const decision = String(values[COL.FINAL_DECISION - 1] || '');
  if (!messageId) throw new Error('Selected row has no Message ID.');
  if (decision !== 'PROCEED' && decision !== 'DO_NOT_PROCEED') {
    throw new Error('Final Decision must be PROCEED or DO_NOT_PROCEED.');
  }

  const message = GmailApp.getMessageById(messageId);
  assertSyntheticMessage_(message);
  const content = buildDraftContent_(values, decision);
  let draftId = String(values[COL.DRAFT_ID - 1] || '');

  if (draftId) {
    const draft = GmailApp.getDraft(draftId);
    draft.update(message.getReplyTo(), content.subject, content.body);
  } else {
    const draft = message.createDraftReply(content.body, {subject: content.subject});
    draftId = draft.getId();
  }

  queue.getRange(row, COL.DRAFT_DECISION).setValue(decision);
  queue.getRange(row, COL.DRAFT_ID).setValue(draftId);
  queue.getRange(row, COL.REVIEW_STATUS).setValue('PENDING');
  queue.getRange(row, COL.WORKFLOW_STATUS).setValue('DRAFT_READY');
  queue.getRange(row, COL.ERROR).setValue('');
}

How does the row become draft-ready?

  • The helper requires a message ID with a valid final decision.
  • The existing synthetic marker check runs before draft creation.
  • A stored Draft ID causes the existing draft to update.
  • A blank Draft ID creates a reply draft from the synthetic source message.
  • Every prepared draft returns Review Status to PENDING.
  • Add the processed-label helper below prepareDraftForRow_ by pasting this code:
function moveToProcessed_(message) {
  const source = ensureLabel_(CONFIG.SOURCE_LABEL);
  const processed = ensureLabel_(CONFIG.PROCESSED_LABEL);
  const thread = message.getThread();
  source.removeFromThread(thread);
  processed.addToThread(thread);
}

Why move the source thread?

The source label represents work waiting for the router. Moving the prepared thread to the processed label prevents the same application from remaining in the incoming queue.

  • Save Review.gs.
  • Confirm the editor accepts both helpers without showing a syntax warning.

Are the helpers failing to parse?

Confirm that prepareDraftForRow_ closes before moveToProcessed_ begins. A missing brace can make both helpers unavailable.

Help me find the misplaced brace in Review.gs.

Both human outcomes need clear synthetic-only language. The feedback outcome uses the visible gaps from the scored row so the reviewer can edit constructive wording.

  • Add the shared draft builder below moveToProcessed_ by pasting this code:
function buildDraftContent_(values, decision) {
  const alias = String(values[COL.CANDIDATE - 1] || 'Synthetic Candidate');
  const gaps = String(values[COL.GAPS - 1] || 'No specific gaps recorded.');
  const disclaimer = [
    'SYNTHETIC TEST MESSAGE',
    'This fictional workflow message must never be sent to a real applicant.',
    '',
  ].join('\n');

  return {
    subject: '[SYNTHETIC] Junior software engineer application feedback',
    body: disclaimer + [
      `Hello ${alias},`,
      '',
      'Thank you for the fictional application. After human review, we will not move this synthetic application forward.',
      '',
      'Constructive areas to strengthen:',
      gaps,
      '',
      'This message is part of a fictional test workflow and is not an employment decision.',
      '',
      'Regards,',
      'Synthetic Hiring Team',
    ].join('\n'),
  };
}

What does the feedback draft contain?

  • The synthetic candidate alias comes from the selected queue row.
  • The constructive section uses the scored gaps that the reviewer can inspect.
  • The disclaimer keeps the message inside the fictional testing boundary.
  • Find the first return { line after the disclaimer.
  • Insert the interview outcome above that line by pasting this code:
  if (decision === 'PROCEED') {
    return {
      subject: '[SYNTHETIC] Proposed face-to-face interview',
      body: disclaimer + [
        `Hello ${alias},`,
        '',
        'Thank you for the fictional application. After human review, we would like to propose a face-to-face interview.',
        `Date: ${getSetting_('INTERVIEW_DATE')}`,
        `Time: ${getSetting_('INTERVIEW_TIME')}`,
        `Location: ${getSetting_('INTERVIEW_LOCATION')}`,
        '',
        'Please reply to this synthetic test message if the proposed slot needs adjustment.',
        '',
        'Regards,',
        'Synthetic Hiring Team',
      ].join('\n'),
    };
  }

How is the interview draft assembled?

  • The PROCEED branch reads the fictional interview details from the Settings tab.
  • The synthetic disclaimer remains at the top of the draft.
  • The feedback outcome remains the valid fallback branch.
  • Save Review.gs.
  • Switch back to the Synthetic Hiring Router spreadsheet.
  • Select a cell in the scored synthetic application row.

Before you prepare the response, predict whether Gmail sends a message or keeps an editable draft.

  • Open the Hiring Router menu.
  • Select Prepare selected draft.

The row now contains a Draft ID. Draft Decision matches Final Decision while Workflow Status shows DRAFT_READY.

  • Switch back to Gmail.
  • Open Gmail's draft folder from the left sidebar.
  • Open the new synthetic draft.
  • Confirm the recipient is your own reply address.
  • Confirm the message begins with SYNTHETIC TEST MESSAGE.
  • Confirm the draft remains unsent.

That is the first gate working. The router can prepare a response while Gmail keeps the wording editable.

  • Open the router-processed Gmail label.
  • Confirm the drafted synthetic thread appears under that label.
  • Open the synthetic-applications Gmail label.
  • Confirm the drafted thread no longer appears under the source label.

No draft in Gmail?

Confirm that you selected the scored data row before using the custom menu. Check the Error column for a missing message ID or invalid final decision.

Help me diagnose why my selected row did not create a draft.

Record the human decision

The draft is a proposal for review. Final Decision records the human outcome while Reviewer Notes preserve the reason behind that choice.

  • Edit one sentence in the Gmail draft while keeping the synthetic warning intact.
  • Return to the Review Queue in the spreadsheet.
  • Choose the human outcome in the Final Decision dropdown.
  • Enter Human review confirmed the evidence supports the selected outcome. in the Reviewer Notes cell.

✔️ Final Decision matches

Draft Decision already matches Final Decision. The existing draft represents the human outcome recorded in the row.

  • Keep Review Status set to PENDING for the blocked-send test.

ⓧ Final Decision changed

The existing draft represents the earlier outcome. Rebuilding updates its content while resetting Review Status to PENDING.

  • Select a cell in the reviewed row.
  • Open the Hiring Router menu.
  • Select Rebuild selected draft.
  • Return to Gmail's draft folder.
  • Open the updated synthetic draft.
  • Confirm its outcome matches Final Decision.
  • Edit the updated wording while keeping the synthetic warning intact.

Why does rebuilding reset approval?

A changed decision can make the earlier draft stale. Resetting Review Status to PENDING forces another review of the replacement wording.

Enforce the send gate

Draft preparation and sending use separate functions. The sender first ignores every row outside the approved draft-ready state.

  • Switch back to Review.gs in the Apps Script editor.
  • Add the sender scaffold below buildDraftContent_ by pasting this code:
function sendApprovedDrafts() {
  const queue = getQueueSheet_();
  if (queue.getLastRow() < 2) return;
  const values = queue.getRange(2, 1, queue.getLastRow() - 1, HEADERS.length).getValues();
  let sent = 0;

  for (let i = 0; i < values.length; i++) {
    const row = i + 2;
    const reviewStatus = String(values[i][COL.REVIEW_STATUS - 1] || '');
    const workflowStatus = String(values[i][COL.WORKFLOW_STATUS - 1] || '');
    if (reviewStatus !== 'APPROVED' || workflowStatus !== 'DRAFT_READY') continue;
  }
  toast_(`${sent} approved synthetic draft(s) sent.`);
}

Where does the first gate apply?

  • The sender reads every queue row below the header.
  • A row must have Review Status set to APPROVED.
  • The same row must have Workflow Status set to DRAFT_READY.
  • Find the status check inside the loop.
  • Insert the decision-match checks directly below it by pasting this code:
    try {
      const finalDecision = String(values[i][COL.FINAL_DECISION - 1] || '');
      const draftDecision = String(values[i][COL.DRAFT_DECISION - 1] || '');
      if (finalDecision !== draftDecision) {
        throw new Error('Final Decision changed after drafting. Rebuild and review the draft.');
      }
      const draftId = String(values[i][COL.DRAFT_ID - 1] || '');
      if (!draftId) throw new Error('Draft ID is missing.');
      GmailApp.getDraft(draftId).send();
      queue.getRange(row, COL.REVIEW_STATUS).setValue('SENT');
      queue.getRange(row, COL.WORKFLOW_STATUS).setValue('SENT');
      queue.getRange(row, COL.SENT_AT).setValue(new Date());
      queue.getRange(row, COL.ERROR).setValue('');
      sent++;
    } catch (error) {
      queue.getRange(row, COL.WORKFLOW_STATUS).setValue('ERROR');
      queue.getRange(row, COL.ERROR).setValue(String(error.message || error).substring(0, 500));
    }

What must pass before sending?

  • Final Decision must exactly match Draft Decision.
  • The row must contain a Draft ID.
  • A successful send changes the review state to SENT.
  • A failed check records a short explanation in the Error column.
  • Save Review.gs.
  • Compare the completed file with the reference below.

✔️ Awesome, I've got everything!

Your saved file contains editable draft creation with the complete human approval gate.

ⓧ I'd like to double check the full code

function prepareSelectedDraft() {
  prepareSelectedDraft_();
}

function rebuildSelectedDraft() {
  prepareSelectedDraft_();
}

function prepareSelectedDraft_() {
  const queue = getQueueSheet_();
  const active = queue.getActiveRange();
  if (!active || active.getRow() < 2) {
    throw new Error('Select a data row in Review Queue first.');
  }
  prepareDraftForRow_(active.getRow());
  const messageId = queue.getRange(active.getRow(), COL.MESSAGE_ID).getValue();
  moveToProcessed_(GmailApp.getMessageById(String(messageId)));
  toast_('Draft prepared. Review it in Gmail before approval.');
}

function prepareDraftForRow_(row) {
  const queue = getQueueSheet_();
  const values = queue.getRange(row, 1, 1, HEADERS.length).getValues()[0];
  const messageId = String(values[COL.MESSAGE_ID - 1] || '');
  const decision = String(values[COL.FINAL_DECISION - 1] || '');
  if (!messageId) throw new Error('Selected row has no Message ID.');
  if (decision !== 'PROCEED' && decision !== 'DO_NOT_PROCEED') {
    throw new Error('Final Decision must be PROCEED or DO_NOT_PROCEED.');
  }

  const message = GmailApp.getMessageById(messageId);
  assertSyntheticMessage_(message);
  const content = buildDraftContent_(values, decision);
  let draftId = String(values[COL.DRAFT_ID - 1] || '');

  if (draftId) {
    const draft = GmailApp.getDraft(draftId);
    draft.update(message.getReplyTo(), content.subject, content.body);
  } else {
    const draft = message.createDraftReply(content.body, {subject: content.subject});
    draftId = draft.getId();
  }

  queue.getRange(row, COL.DRAFT_DECISION).setValue(decision);
  queue.getRange(row, COL.DRAFT_ID).setValue(draftId);
  queue.getRange(row, COL.REVIEW_STATUS).setValue('PENDING');
  queue.getRange(row, COL.WORKFLOW_STATUS).setValue('DRAFT_READY');
  queue.getRange(row, COL.ERROR).setValue('');
}

function buildDraftContent_(values, decision) {
  const alias = String(values[COL.CANDIDATE - 1] || 'Synthetic Candidate');
  const gaps = String(values[COL.GAPS - 1] || 'No specific gaps recorded.');
  const disclaimer = [
    'SYNTHETIC TEST MESSAGE',
    'This fictional workflow message must never be sent to a real applicant.',
    '',
  ].join('\n');

  if (decision === 'PROCEED') {
    return {
      subject: '[SYNTHETIC] Proposed face-to-face interview',
      body: disclaimer + [
        `Hello ${alias},`,
        '',
        'Thank you for the fictional application. After human review, we would like to propose a face-to-face interview.',
        `Date: ${getSetting_('INTERVIEW_DATE')}`,
        `Time: ${getSetting_('INTERVIEW_TIME')}`,
        `Location: ${getSetting_('INTERVIEW_LOCATION')}`,
        '',
        'Please reply to this synthetic test message if the proposed slot needs adjustment.',
        '',
        'Regards,',
        'Synthetic Hiring Team',
      ].join('\n'),
    };
  }

  return {
    subject: '[SYNTHETIC] Junior software engineer application feedback',
    body: disclaimer + [
      `Hello ${alias},`,
      '',
      'Thank you for the fictional application. After human review, we will not move this synthetic application forward.',
      '',
      'Constructive areas to strengthen:',
      gaps,
      '',
      'This message is part of a fictional test workflow and is not an employment decision.',
      '',
      'Regards,',
      'Synthetic Hiring Team',
    ].join('\n'),
  };
}

function sendApprovedDrafts() {
  const queue = getQueueSheet_();
  if (queue.getLastRow() < 2) return;
  const values = queue.getRange(2, 1, queue.getLastRow() - 1, HEADERS.length).getValues();
  let sent = 0;

  for (let i = 0; i < values.length; i++) {
    const row = i + 2;
    const reviewStatus = String(values[i][COL.REVIEW_STATUS - 1] || '');
    const workflowStatus = String(values[i][COL.WORKFLOW_STATUS - 1] || '');
    if (reviewStatus !== 'APPROVED' || workflowStatus !== 'DRAFT_READY') continue;

    try {
      const finalDecision = String(values[i][COL.FINAL_DECISION - 1] || '');
      const draftDecision = String(values[i][COL.DRAFT_DECISION - 1] || '');
      if (finalDecision !== draftDecision) {
        throw new Error('Final Decision changed after drafting. Rebuild and review the draft.');
      }
      const draftId = String(values[i][COL.DRAFT_ID - 1] || '');
      if (!draftId) throw new Error('Draft ID is missing.');
      GmailApp.getDraft(draftId).send();
      queue.getRange(row, COL.REVIEW_STATUS).setValue('SENT');
      queue.getRange(row, COL.WORKFLOW_STATUS).setValue('SENT');
      queue.getRange(row, COL.SENT_AT).setValue(new Date());
      queue.getRange(row, COL.ERROR).setValue('');
      sent++;
    } catch (error) {
      queue.getRange(row, COL.WORKFLOW_STATUS).setValue('ERROR');
      queue.getRange(row, COL.ERROR).setValue(String(error.message || error).substring(0, 500));
    }
  }
  toast_(`${sent} approved synthetic draft(s) sent.`);
}

function moveToProcessed_(message) {
  const source = ensureLabel_(CONFIG.SOURCE_LABEL);
  const processed = ensureLabel_(CONFIG.PROCESSED_LABEL);
  const thread = message.getThread();
  source.removeFromThread(thread);
  processed.addToThread(thread);
}

What should match?

Compare every function name with your saved file. Confirm that draft creation remains separate from approved sending.

Before you test the completed gate, predict whether a row with Review Status set to PENDING can send its draft.

  • Switch back to the Synthetic Hiring Router spreadsheet.
  • Confirm Review Status remains PENDING.
  • Open the Hiring Router menu.
  • Select Send approved drafts.

The spreadsheet reports 0 approved synthetic draft(s) sent. The row remains DRAFT_READY with its Draft ID intact.

  • Return to Gmail's draft folder.
  • Confirm the edited synthetic draft remains unsent.
  • Return to the Review Queue.
  • Confirm Draft Decision still matches Final Decision.

What unlocks sending?

The sender requires Review Status APPROVED with Workflow Status DRAFT_READY. It also requires a Draft ID with matching final and draft decisions.

The pending test proves that draft creation alone never authorizes delivery. Human approval remains the final control.

Did the pending draft send?

Confirm that Review Status was PENDING before you used the menu. Check that your saved sendApprovedDrafts function contains the status check before the sending block.

Help me inspect the pending send gate safely.

Your router now prepares editable responses without giving the model permission to send them. Next, you will extend the same gate across resumable batches.

Automate Resumable Batch Routing

Your reviewer gate now protects every synthetic send. The router still handles only one selected application at a time.

A burst of fictional applications needs a worker that can claim a small batch. Failed items must remain available for another attempt.

Google Apps Script will use a script lock to prevent overlapping runs. Gmail labels will show each thread's routing state.

In this step, get ready to:
  • Build a locked batch worker with quarantine handling.
  • Install a five-minute automation trigger.
  • Verify each routed thread's label and queue status.
Build the locked batch worker

The worker reads the configured BATCH_SIZE value before claiming source threads. The current setting limits each execution to five threads.

Each message must contain [SYNTHETIC] in its subject. Its body must also contain SYNTHETIC TEST DATA before any PDF reaches the model.

  • Return to the bound Apps Script editor from earlier.
  • Use the file sidebar's add control to create a script file named Router.

You'll see Router.gs in the file sidebar.

The main worker crosses two code chunks so each part stays readable. The second chunk closes every open block before the function is saved or run.

  • Paste this opening into the empty Router.gs file:
function processSyntheticApplications() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) {
    console.log('Another router execution already holds the lock.');
    return;
  }

  try {
    const source = ensureLabel_(CONFIG.SOURCE_LABEL);
    const batchSize = Math.max(1, Number(getSetting_('BATCH_SIZE')) || 5);
    const threads = source.getThreads(0, batchSize);
    let completed = 0;

    for (let i = 0; i < threads.length; i++) {
      let item;
      try {
        item = getSyntheticItemFromThread_(threads[i]);
        assertSyntheticMessage_(item.message);
      } catch (safetyError) {
        quarantineThread_(threads[i], safetyError);
        continue;
      }

How Does the Opening Work?

  • LockService.getScriptLock() gives one execution ownership of the shared routing workflow.
  • tryLock(5000) exits when another execution already owns the lock.
  • getThreads(0, batchSize) reads only the configured batch from the source label.
  • assertSyntheticMessage_() blocks a message that lacks either required synthetic marker.
  • Continue directly below the opening with this closing chunk:

      try {
        const analysis = analyzePdf_(item.pdf);
        const row = writeAnalysisRow_(item.message, item.pdf, analysis, 'ANALYZED');
        prepareDraftForRow_(row);
        moveToProcessed_(item.message);
        completed++;
      } catch (error) {
        recordError_(item.message, item.pdf, error);
      }
    }

    SpreadsheetApp.flush();
    console.log(`Completed ${completed} synthetic application(s) in this batch.`);
  } finally {
    lock.releaseLock();
  }
}

How Does the Batch Finish?

  • analyzePdf_() scores one attached PDF after the safety check passes.
  • prepareDraftForRow_() creates an unsent draft for the queue row.
  • recordError_() preserves an analysis or PDF failure in the Review Queue.
  • releaseLock() frees the router even when processing throws an error.
  • Save Router.gs.
  • Open the function selector in the Apps Script toolbar.

You'll see processSyntheticApplications as a selectable function. This confirms that the completed function parses successfully.

Worker Function Missing?

Check that the second chunk sits inside the closing half of processSyntheticApplications. A missing brace can prevent Apps Script from listing the function.

Help me compare my processSyntheticApplications function with the intended Router.gs structure.

Unsafe messages need a terminal state that excludes them from later batch runs. The quarantine helper removes the source label before adding the quarantine label.

  • Add the quarantine helper below processSyntheticApplications by pasting this code:
function quarantineThread_(thread, reason) {
  const source = ensureLabel_(CONFIG.SOURCE_LABEL);
  const quarantine = ensureLabel_(CONFIG.QUARANTINE_LABEL);
  source.removeFromThread(thread);
  quarantine.addToThread(thread);
  console.log(`Thread quarantined without analysis: ${String(reason.message || reason)}`);
}

What Does Quarantine Protect?

  • removeFromThread() prevents the unsafe thread from being claimed again.
  • addToThread() places the thread under router-quarantine for inspection.
  • The helper never calls analyzePdf_(). Unsafe content stays outside the model request.

Retryable failures follow a different path. The retry helper reuses the selected queue row after you diagnose its Error value.

  • Add the retry helper below quarantineThread_ by pasting this code:
function retrySelectedApplication() {
  const queue = getQueueSheet_();
  const active = queue.getActiveRange();
  if (!active || active.getRow() < 2) {
    throw new Error('Select an error row in Review Queue first.');
  }

  const row = active.getRow();
  const messageId = String(queue.getRange(row, COL.MESSAGE_ID).getValue() || '');
  if (!messageId) throw new Error('Selected row has no Message ID.');

  const message = GmailApp.getMessageById(messageId);
  assertSyntheticMessage_(message);
  const pdf = findPdfAttachment_(message);
  if (!pdf) throw new Error('The selected synthetic message has no PDF.');

  try {
    const analysis = analyzePdf_(pdf);
    const updatedRow = writeAnalysisRow_(message, pdf, analysis, 'ANALYZED');
    prepareDraftForRow_(updatedRow);
    moveToProcessed_(message);
    toast_('Selected synthetic application retried successfully.');
  } catch (error) {
    recordError_(message, pdf, error);
    throw error;
  }
}

How Does a Retry Resume?

  • getActiveRange() identifies the Review Queue row you selected.
  • getMessageById() reconnects the row to its original synthetic message.
  • writeAnalysisRow_() updates the existing row because the Message ID already exists.
  • moveToProcessed_() removes the source label only after the retry succeeds.
  • Save Router.gs.
  • Open the function selector in the Apps Script toolbar.

You'll see processSyntheticApplications and retrySelectedApplication in the selector. The private helper quarantineThread_ remains available to the worker.

Seeing a Save Error?

Confirm that each helper starts after the preceding function's final brace. Check that Router.gs contains only one copy of each function.

Help me find the syntax or placement problem in my Router.gs file.

✔️ Awesome, I've got everything!

Your Router.gs file now contains locked batch processing, quarantine handling, and a selected-row retry path.

ⓧ I'd like to double check the full code

function processSyntheticApplications() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) {
    console.log('Another router execution already holds the lock.');
    return;
  }

  try {
    const source = ensureLabel_(CONFIG.SOURCE_LABEL);
    const batchSize = Math.max(1, Number(getSetting_('BATCH_SIZE')) || 5);
    const threads = source.getThreads(0, batchSize);
    let completed = 0;

    for (let i = 0; i < threads.length; i++) {
      let item;
      try {
        item = getSyntheticItemFromThread_(threads[i]);
        assertSyntheticMessage_(item.message);
      } catch (safetyError) {
        quarantineThread_(threads[i], safetyError);
        continue;
      }

      try {
        const analysis = analyzePdf_(item.pdf);
        const row = writeAnalysisRow_(item.message, item.pdf, analysis, 'ANALYZED');
        prepareDraftForRow_(row);
        moveToProcessed_(item.message);
        completed++;
      } catch (error) {
        recordError_(item.message, item.pdf, error);
      }
    }

    SpreadsheetApp.flush();
    console.log(`Completed ${completed} synthetic application(s) in this batch.`);
  } finally {
    lock.releaseLock();
  }
}

function quarantineThread_(thread, reason) {
  const source = ensureLabel_(CONFIG.SOURCE_LABEL);
  const quarantine = ensureLabel_(CONFIG.QUARANTINE_LABEL);
  source.removeFromThread(thread);
  quarantine.addToThread(thread);
  console.log(`Thread quarantined without analysis: ${String(reason.message || reason)}`);
}

function retrySelectedApplication() {
  const queue = getQueueSheet_();
  const active = queue.getActiveRange();
  if (!active || active.getRow() < 2) {
    throw new Error('Select an error row in Review Queue first.');
  }

  const row = active.getRow();
  const messageId = String(queue.getRange(row, COL.MESSAGE_ID).getValue() || '');
  if (!messageId) throw new Error('Selected row has no Message ID.');

  const message = GmailApp.getMessageById(messageId);
  assertSyntheticMessage_(message);
  const pdf = findPdfAttachment_(message);
  if (!pdf) throw new Error('The selected synthetic message has no PDF.');

  try {
    const analysis = analyzePdf_(pdf);
    const updatedRow = writeAnalysisRow_(message, pdf, analysis, 'ANALYZED');
    prepareDraftForRow_(updatedRow);
    moveToProcessed_(message);
    toast_('Selected synthetic application retried successfully.');
  } catch (error) {
    recordError_(message, pdf, error);
    throw error;
  }
}

What Should Match?

Compare all three functions with your saved file. The function names, labels, status values, and helper calls must match exactly.

Install the recurring batch trigger

A time-based trigger gives the router a recurring wake-up point. The existing installer removes earlier copies before creating one five-minute trigger.

  • Select installBatchAutomation from the Apps Script function selector.
  • Start the selected function with the toolbar's play control.
  • Open the trigger page from the Apps Script sidebar.

You'll see exactly one time-based trigger for processSyntheticApplications. Its schedule repeats every five minutes.

Trigger Missing or Duplicated?

Run installBatchAutomation again from the function selector. The installer deletes existing triggers for the same handler before creating the replacement.

Help me diagnose why my processSyntheticApplications trigger is missing or duplicated.

The source label is the worker's queue. Only clearly marked fictional messages belong in that queue.

  • Switch back to Gmail from earlier.
  • Select the remaining self-sent messages with both required synthetic markers.
  • Use the label control to apply synthetic-applications.
  • Open the synthetic-applications label.

You'll see only the fictional threads waiting for batch routing. The thread processed in the previous step remains under router-processed.

Run and inspect one batch

A manual run uses the same handler as the recurring trigger. This gives you an immediate view of the labels, queue rows, and drafts produced by one batch.

Before you start it, how many waiting threads do you expect the batch to claim when BATCH_SIZE is 5?

  • Switch back to the Apps Script editor.
  • Select processSyntheticApplications from the function selector.
  • Start the selected function with the toolbar's play control.
  • Open the latest run in the Apps Script execution history.

You'll see the execution finish. Its log reports how many synthetic applications completed in the batch.

Did a Row End in ERROR?

A transient Gemini or PDF failure leaves the source label in place. The Review Queue records ERROR in Workflow Status with diagnostic text in the Error column.

  • Select the failed row in the Review Queue.
  • Return to the Apps Script function selector.
  • Select retrySelectedApplication.
  • Start the selected retry function with the toolbar's play control.

Help me diagnose an ERROR row before I retry the synthetic application.

  • Return to Gmail from earlier.
  • Open the router-processed label to confirm the successful batch threads moved there.
  • Open the synthetic-applications label to confirm it contains only waiting or retryable threads.
  • Open the router-quarantine label to confirm any unsafe threads moved there.
  • Return to the Review Queue tab in Google Sheets.

Each successful batch row has scores, evidence, a Draft ID, and DRAFT_READY in Workflow Status. Its Review Status remains PENDING.

What Did the Batch Prove?

  • Removing synthetic-applications prevents a successful thread from being claimed again.
  • Adding router-processed records that the thread completed routing.
  • Keeping the source label on an error leaves that thread available for retrySelectedApplication.
  • A successful batch creates an unsent draft through prepareDraftForRow_().
  • sendApprovedDrafts still requires an existing Draft ID.
  • Sending still requires APPROVED in Review Status.
  • Sending still requires matching Final Decision and Draft Decision values.

That is a major safety upgrade. Your router now resumes in small batches without bypassing human approval.

Next, you'll simulate a much larger burst and prove that stale draft decisions remain blocked.

Load-Test and Troubleshoot Safely

Your locked batch worker in Google Apps Script now routes synthetic applications into editable drafts. Human approval still controls every send.

A workflow that succeeds once can still fail during a burst. It can also act on an outdated draft after a reviewer changes the final decision.

In this step, get ready to:
  • Simulate 200 work items in Google Sheets without creating emails or model calls.
  • Check the API key, labels, queue structure, trigger, row count, and unique dry-run IDs.
  • Prove that a stale Gmail draft cannot pass the human approval gate.
Seed a 200-item dry run

A dry run models the workload while keeping every item inside the spreadsheet. It avoids burst-test emails, drafts, sends, and Gemini API calls.

  • Switch back to the Apps Script editor from earlier.
  • Add a script file named Tests so the editor saves it as Tests.gs.
  • Create the dry-run seeding function by pasting this code into Tests.gs:
function seedSyntheticBurstTest() {
  const spreadsheet = getSpreadsheet_();
  let sheet = spreadsheet.getSheetByName(CONFIG.BURST_SHEET);
  if (!sheet) sheet = spreadsheet.insertSheet(CONFIG.BURST_SHEET);
  sheet.clear();

  const batchSize = Math.max(1, Number(getSetting_('BATCH_SIZE')) || 5);
  const rows = [['Synthetic Work Item', 'Status', 'Planned Batch', 'API Called', 'Email Sent']];
  for (let i = 1; i <= 200; i++) {
    rows.push([
      `SYNTHETIC-LOAD-${String(i).padStart(3, '0')}`,
      'QUEUED_DRY_RUN',
      Math.ceil(i / batchSize),
      'NO',
      'NO',
    ]);
  }

  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  toast_('Seeded 200 dry-run items without emails or Gemini calls.');
}

What does this code do?

  • The function creates or resets the Burst Test sheet.
  • The loop generates exactly 200 unique work item IDs.
  • The planned batch uses the existing BATCH_SIZE setting.
  • The final two columns prove that the simulation makes no API calls or email sends.
  • Save Tests.gs.
  • Return to the Synthetic Hiring Router sheet from earlier.
  • Refresh the sheet to load the updated custom menu.

Before you seed the burst, how many planned batches do you expect when each batch holds five items?

  • Open the Hiring Router menu.
  • Select Seed 200-item burst test.
  • Open the Burst Test tab.
  • Scroll to the final work item.

You should see 201 rows including the header. The final item is SYNTHETIC-LOAD-200 in planned batch 40.

You have now modeled a 200-item burst without creating 200 messages or consuming 200 model calls.

Burst Test missing or incomplete?

Confirm that Tests.gs is saved. Check that the loop ends at 200.

If the planned batches do not end at 40, confirm that BATCH_SIZE remains 5 in the Settings sheet.

Help me diagnose my synthetic Burst Test results.

Add the troubleshooting checks

A troubleshooting function turns the router's hidden assumptions into repeatable checks. Start with the API key and workflow labels.

  • Switch back to Tests.gs in the Apps Script editor.
  • Paste this function below seedSyntheticBurstTest:
function runTroubleshootingChecks() {
  const problems = [];
  const properties = PropertiesService.getScriptProperties();
  if (!properties.getProperty(CONFIG.API_KEY_PROPERTY)) {
    problems.push('GEMINI_API_KEY is missing.');
  }

  if (!GmailApp.getUserLabelByName(CONFIG.SOURCE_LABEL)) {
    problems.push('Source Gmail label is missing.');
  }
  if (!GmailApp.getUserLabelByName(CONFIG.PROCESSED_LABEL)) {
    problems.push('Processed Gmail label is missing.');
  }
  if (!GmailApp.getUserLabelByName(CONFIG.QUARANTINE_LABEL)) {
    problems.push('Quarantine Gmail label is missing.');
  }

  const message = problems.length === 0
    ? 'All checks passed. The workflow remains synthetic-only.'
    : problems.join('\n');
  console.log(message);
  toast_(message);
}

What does this first check cover?

  • The properties check confirms that the existing GEMINI_API_KEY property is available.
  • The label checks confirm that the source, processed, and quarantine paths still exist.
  • The problems array collects every failed condition into one report.
  • Save Tests.gs.
  • Return to the Synthetic Hiring Router sheet.
  • Open the Hiring Router menu.
  • Select Run troubleshooting checks.

The toast now reports whether the stored key and three workflow labels pass this first check.

Key or label check failing?

A missing key points to Script Properties. A missing label points to the Gmail workflow setup.

Help me diagnose the failed key or Gmail label check.

The next check protects the sheet schema and confirms that the recurring batch worker still has one installed trigger.

  • Find the line in runTroubleshootingChecks that starts with const message.
  • Insert this block immediately above that line:
  const queue = getQueueSheet_();
  const headerValues = queue.getRange(1, 1, 1, HEADERS.length).getValues()[0];
  if (headerValues.join('|') !== HEADERS.join('|')) {
    problems.push('Review Queue headers do not match the code.');
  }

  const hasTrigger = ScriptApp.getProjectTriggers().some(function(trigger) {
    return trigger.getHandlerFunction() === CONFIG.TRIGGER_HANDLER;
  });
  if (!hasTrigger) problems.push('Five-minute automation is not installed.');

What do these checks protect?

  • The header comparison detects a renamed, removed, or reordered Review Queue column.
  • The trigger check looks for the configured processSyntheticApplications handler.
  • A failed condition joins the same problems report as the key and label checks.
  • Save Tests.gs.
  • Return to the Synthetic Hiring Router sheet.
  • Run Run troubleshooting checks from the Hiring Router menu.

The updated check now includes the Review Queue header order and the five-minute automation trigger.

Header or trigger check failing?

Compare the Review Queue header row with the headings created by the router setup. Confirm that the existing trigger still invokes processSyntheticApplications.

Help me diagnose the queue or trigger check.

The final checks prove that the dry run has the expected size and no duplicate work item IDs.

  • Find the same line in runTroubleshootingChecks that starts with const message.
  • Insert this block immediately above that line:
  const burst = getSpreadsheet_().getSheetByName(CONFIG.BURST_SHEET);
  if (!burst || burst.getLastRow() !== 201) {
    problems.push('Burst Test does not contain 200 dry-run items.');
  } else {
    const ids = burst.getRange(2, 1, 200, 1).getValues().map(function(row) {
      return String(row[0]);
    });
    if (new Set(ids).size !== 200) problems.push('Burst Test contains duplicate IDs.');
  }

How does duplicate protection get tested?

  • The row-count check expects one header plus 200 dry-run items.
  • The ID check reads all 200 work item names into a set.
  • A set size below 200 reveals at least one duplicate ID.
  • Save Tests.gs.
  • Return to the Synthetic Hiring Router sheet.

Before you run the complete check, do you expect it to find any missing resources or duplicate IDs?

  • Open the Hiring Router menu.
  • Select Run troubleshooting checks.

You should see All checks passed. The workflow remains synthetic-only. This confirms the key, labels, headers, trigger, row count, and unique IDs.

  • Open the Apps Script execution history.
  • Inspect the latest troubleshooting run for its completion status.
  • Return to the Review Queue sheet.
  • Inspect the Error column on any row with ERROR workflow status.

How to read different failures

The troubleshooting toast exposes missing setup resources. The execution history helps identify runtime, quota, or transient service failures.

The Review Queue Error column ties PDF, API, retry, or approval failures to a specific synthetic application row.

Complete check still failing?

Use the exact problem listed in the toast as your starting point. Fix that resource before running the complete check again.

Help me work through my failed router check.

✔️ Awesome, I've got everything!

Your Tests.gs file now seeds the safe burst simulation and checks the router's required resources.

ⓧ I'd like to double check the full code

function seedSyntheticBurstTest() {
  const spreadsheet = getSpreadsheet_();
  let sheet = spreadsheet.getSheetByName(CONFIG.BURST_SHEET);
  if (!sheet) sheet = spreadsheet.insertSheet(CONFIG.BURST_SHEET);
  sheet.clear();

  const batchSize = Math.max(1, Number(getSetting_('BATCH_SIZE')) || 5);
  const rows = [['Synthetic Work Item', 'Status', 'Planned Batch', 'API Called', 'Email Sent']];
  for (let i = 1; i <= 200; i++) {
    rows.push([
      `SYNTHETIC-LOAD-${String(i).padStart(3, '0')}`,
      'QUEUED_DRY_RUN',
      Math.ceil(i / batchSize),
      'NO',
      'NO',
    ]);
  }

  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  toast_('Seeded 200 dry-run items without emails or Gemini calls.');
}

function runTroubleshootingChecks() {
  const problems = [];
  const properties = PropertiesService.getScriptProperties();
  if (!properties.getProperty(CONFIG.API_KEY_PROPERTY)) {
    problems.push('GEMINI_API_KEY is missing.');
  }

  if (!GmailApp.getUserLabelByName(CONFIG.SOURCE_LABEL)) {
    problems.push('Source Gmail label is missing.');
  }
  if (!GmailApp.getUserLabelByName(CONFIG.PROCESSED_LABEL)) {
    problems.push('Processed Gmail label is missing.');
  }
  if (!GmailApp.getUserLabelByName(CONFIG.QUARANTINE_LABEL)) {
    problems.push('Quarantine Gmail label is missing.');
  }

  const queue = getQueueSheet_();
  const headerValues = queue.getRange(1, 1, 1, HEADERS.length).getValues()[0];
  if (headerValues.join('|') !== HEADERS.join('|')) {
    problems.push('Review Queue headers do not match the code.');
  }

  const hasTrigger = ScriptApp.getProjectTriggers().some(function(trigger) {
    return trigger.getHandlerFunction() === CONFIG.TRIGGER_HANDLER;
  });
  if (!hasTrigger) problems.push('Five-minute automation is not installed.');

  const burst = getSpreadsheet_().getSheetByName(CONFIG.BURST_SHEET);
  if (!burst || burst.getLastRow() !== 201) {
    problems.push('Burst Test does not contain 200 dry-run items.');
  } else {
    const ids = burst.getRange(2, 1, 200, 1).getValues().map(function(row) {
      return String(row[0]);
    });
    if (new Set(ids).size !== 200) problems.push('Burst Test contains duplicate IDs.');
  }

  const message = problems.length === 0
    ? 'All checks passed. The workflow remains synthetic-only.'
    : problems.join('\n');
  console.log(message);
  toast_(message);
}
Test a changed final decision

A prepared draft captures the decision that existed when the draft was built. Changing the human decision creates a mismatch that the send gate must handle safely.

  • Return to the Review Queue tab.
  • Select a synthetic row with DRAFT_READY in the Workflow Status column.
  • Confirm that Final Decision currently matches Draft Decision.
  • Change Final Decision to the opposite dropdown value.
  • Set Review Status to APPROVED.

Before you run the send action, do you think the existing draft can pass with two different decisions?

  • Open the Hiring Router menu.
  • Select Send approved drafts.

The message remains unsent. The row changes to ERROR with Final Decision changed after drafting. Rebuild and review the draft.

This block is intentional

The draft represents an outdated decision. The exact-match check prevents approval from authorizing content that the reviewer has not re-reviewed.

The human decision now needs a replacement draft. Rebuilding it also resets approval so the changed response receives a fresh review.

  • Select the same Review Queue row.
  • Open the Hiring Router menu.
  • Select Rebuild selected draft.
  • Switch back to Gmail from earlier.
  • Open the replacement synthetic draft.
  • Review the replacement wording against the changed Final Decision.
  • Edit the synthetic wording if needed.
  • Return to the Review Queue sheet.

Draft Decision now matches Final Decision. Review Status has returned to PENDING while Workflow Status shows DRAFT_READY.

  • Set Review Status to APPROVED after completing the replacement review.

Before the final send, do you expect the matching replacement draft to pass the same gate?

  • Open the Hiring Router menu.
  • Select Send approved drafts.
  • Switch back to Gmail to confirm the corrected synthetic response was sent to yourself.

That is the safety case proven. A stale response was blocked while the re-reviewed human decision passed the approval gate.

Corrected draft still blocked?

Confirm that Final Decision and Draft Decision contain the same value. Confirm that Review Status is APPROVED before running the send action.

Help me diagnose the synthetic approval gate.

Secret mission

Catch and Correct a Tricky Misclassification

Create a deliberately tricky fictional CV with job-relevant evidence hidden inside project bullets. Catch a weak recommendation, document the human correction, and rebuild the draft around your judgment.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your synthetic router active, pause its automation, or delete the project entirely. The Gemini API free tier keeps this project at $0 within its eligibility and rate limits.

Keep the safety boundary

The free Gemini API tier uses submitted content to improve Google products. Real applicant data remains outside this sandbox.

A consumer Gmail account cannot support hundreds of same-day responses through this Google Apps Script prototype. The human approval gate remains the only safe send path.

Resources you used:

  • A Google Sheets file named Synthetic Hiring Router with Review Queue, Settings, and Burst Test tabs.
  • A bound Google Apps Script project attached to Synthetic Hiring Router.
  • A stored Script Property named GEMINI_API_KEY.
  • One five-minute trigger for processSyntheticApplications.
  • Three Gmail workflow labels named synthetic-applications, router-processed, and router-quarantine.
  • Router-created synthetic drafts linked from the review queue.
  • Self-sent synthetic application emails and synthetic responses.
  • Fictional Google Docs CV source files.
  • Fictional PDF CV files downloaded from those source documents.

Keep everything running

No action is needed while you continue testing the router with fictional data. The five-minute trigger continues checking the source label for synthetic applications.

  • Keep the processSyntheticApplications trigger active only while you are testing the router.
  • Retain Synthetic Hiring Router as the audit record for your fictional reviews.
  • Retain the three Gmail workflow labels for duplicate protection and quarantine handling.
  • Retain only fictional Google Docs files and PDF CVs.
  • Keep every future subject marked with [SYNTHETIC].
  • Keep every future message body marked with SYNTHETIC TEST DATA.

Pause - I'll come back to this later

Pausing stops automatic inbox processing while preserving the sheet and its synthetic review history. Existing drafts remain available for later review.

  • Switch back to the Apps Script editor from earlier.
  • Run removeBatchAutomation.

Your router is now paused. The spreadsheet confirms that batch automation was removed.

  • Retain Synthetic Hiring Router with its review and burst-test data.
  • Retain the synthetic Gmail labels and messages.
  • Retain the unsent synthetic drafts for later review.
  • Retain the fictional Google Docs files and PDF CVs.

Delete - I don't want to use this again

Complete removal clears the router and its fictional data. This is a broad cleanup, so the automated teardown happens before you permanently delete the files.

  • Switch back to the Apps Script editor from earlier.
  • Run cleanupSyntheticProject.

That removes the five-minute trigger, tracked drafts, workflow labels, router data, and stored API key. Your synthetic emails and files remain available for manual deletion.

  • Locate every project message in Gmail by its [SYNTHETIC] subject marker.
  • Move all self-sent synthetic application emails to trash.
  • Move all synthetic responses sent by the router to trash.
  • Move any remaining synthetic router draft to trash.
  • Permanently delete only the project messages from Gmail trash.

Your Gmail test trail is now removed.

  • In Google Drive, move every fictional Google Docs CV source file to trash.
  • Move Synthetic Hiring Router to Google Drive trash.
  • Permanently delete the fictional source documents and Synthetic Hiring Router from Google Drive trash.

The online project files are now removed.

  • Use File Explorer to navigate to your browser's download location.
  • Delete every fictional PDF CV created for this project.
  • Permanently delete those fictional PDFs from the Windows Recycle Bin.

The remaining PDF copies are now removed from Windows.

Nice Work!

Nice Work!

You made it! Your synthetic-only hiring router now turns fictional PDF applications into reviewable decisions. Human approval controls every send.

What you learned:

  • Connected Gmail to Google Sheets with Google Apps Script. Used Gemini for transparent six-part scoring of fictional PDF CVs.
  • Created editable drafts for proceed and do-not-proceed outcomes. Enforced a human approval gate before every send.
  • Ran a resumable batch worker with locking and retries. Prevented duplicate processing with workflow labels. Proved safe scale with a 200-item dry-run that sent no emails and made no model calls.
  • Secret Mission: Caught a misleading synthetic CV recommendation by checking its scores against the source document. Recorded a human override and rebuilt the draft around the reviewer’s decision.

Ready to quiz yourself?