Table of Contents
- 1 The Business Situation
- 2 The Existing Process
- 2.1 Operational problems
- 2.2 Practical business effects
- 3 What the New System Needed to Do
- 4 Implementation Approaches Considered
- 4.1 A more disciplined manual process
- 4.2 Google Workspace with Apps Script
- 4.3 Airtable
- 4.4 A dedicated applicant tracking platform
- 5 The Selected Solution
- 6 System Architecture and Data Flow
- 7 Data Structure
- 8 Workflow Statuses and Ownership
- 9 Step-by-Step Implementation
- 9.1 Step 1: Prepare the Accounts and Permissions
- 9.2 Step 2: Build the Intake
- 9.3 Step 3: Create the System of Record
- 9.4 Step 4: Connect the Tools
- 9.5 Step 5: Build the Core Automation
- 9.6 Step 6: Add Approvals, Reminders, and Escalations
- 9.7 Step 7: Add Documents and File Management
- 9.8 Step 8: Add Reporting and Operational Views
- 9.9 Step 9: Add Security and Governance Controls
- 9.10 Step 10: Deploy and Test
- 10 Code and Configuration
- 11 Failure Handling and Operational Reliability
- 12 A Complete Example
- 13 Implementation Cost
- 14 Estimated Time and Cost Savings
- 15 Adding AI to the Automation
- 15.1 The Recommended AI Enhancement
- 15.2 Benefits of the AI Enhancement
- 15.3 What Remains Rule-Based or Human-Controlled
- 15.4 Estimating the Additional Value of AI
- 16 Testing Checklist
- 17 Ongoing Maintenance
- 18 When to Move to Dedicated Software
- 19 Implementation Checklist
The Business Situation
Cedarline Consulting is a fictional 55-person professional services firm. It typically recruits for three to five consulting, project management, and administrative roles at a time.
The People Operations team consists of an HR manager and a recruiting coordinator. Department leaders act as hiring managers, while approximately 12 employees participate in interviews during a typical quarter.
The firm receives about 60 applications each month through three channels:
- A careers page that directs applicants to a web form.
- LinkedIn job listings that either direct applicants to the form or produce application emails requiring recruiter review.
- Direct applications sent to a shared recruiting mailbox.
Candidate details were tracked in one Google Sheet. Resumes were stored inconsistently in Gmail, personal Google Drive folders, or a shared recruiting folder. Interview scheduling happened through email and Google Calendar, but calendar event identifiers and feedback status were not connected to the candidate record.
The spreadsheet was useful as a list, but it was not functioning as an applicant tracking system. The recruiting coordinator had to create rows, rename files, create folders, notify hiring managers, schedule interviews, send reminders, and reconcile statuses manually.
The business needed a more controlled process before application volume increased or responsibility moved to another employee.
Note: This case study is provided as a representative example of the types of AI integration and digital transformation solutions Intelligex designs and delivers. Actual engagements are tailored to each client’s goals, constraints, existing systems, timeline, and available resources, so the approach, tools, and outcomes may vary.
The Existing Process
The original workflow followed a broadly consistent sequence, but most steps depended on the recruiting coordinator remembering to complete them.
- An applicant completed the website form, applied through LinkedIn, or emailed a resume.
- The recruiting coordinator opened the submission and checked whether a corresponding spreadsheet row already existed.
- The coordinator copied the applicant’s name, email address, job, source, and notes into the candidate spreadsheet.
- The resume was downloaded or moved into a Drive folder. Folder and file names depended on who processed the application.
- The coordinator looked up the responsible recruiter and hiring manager, then sent a review request by email.
- The hiring manager replied by email or verbally indicated whether the candidate should advance.
- The coordinator asked the candidate for interview availability, created a calendar event, and invited the interview panel.
- After the interview, the coordinator followed up with panel members for feedback.
- Offer approval, rejection, and candidate communications were managed through separate email threads.
- The spreadsheet status was updated when the coordinator had time to reconcile the emails and calendar events.
Operational problems
- Applicant details were copied manually from several sources.
- The same candidate could appear twice for the same job.
- Required fields varied by intake channel.
- Resumes were stored in multiple locations.
- Hiring-stage definitions were interpreted differently.
- Owners and deadlines were not always recorded.
- Interview and feedback reminders depended on memory.
Practical business effects
- Recruiters spent time on administrative data entry.
- Hiring managers sometimes received duplicate review requests.
- Incomplete applications required extra email exchanges.
- Access to candidate documents was difficult to audit.
- Leadership could not reliably measure pipeline volume or aging.
- Delayed feedback extended candidate response times.
- The process depended heavily on one coordinator.
The main problem was not the spreadsheet itself. It was the absence of controlled intake, validation, automatic ownership, defined status transitions, document links, reminders, and an audit history around the spreadsheet.
What the New System Needed to Do
The implementation requirements were agreed before selecting a tool. This prevented the project from becoming a spreadsheet redesign without meaningful process improvement.
| Area | Requirement | Control |
|---|---|---|
| Intake | Accept website, LinkedIn-routed, and email applications. | Use a standard form where possible and a controlled Gmail label for email intake. |
| Validation | Require a candidate name, valid email, job code, source, and privacy acknowledgement. | Form validation and server-side Apps Script validation. |
| Record creation | Create one candidate record per person and job. | Check source event IDs and normalized candidate email plus job code. |
| Identification | Generate a stable candidate identifier. | Use the format AP-YYYY-NNNN. |
| Ownership | Assign each stage to a named person. | Look up recruiters, hiring managers, and offer approvers from a Jobs sheet. |
| Documents | Create a controlled folder and store resume copies. | Use a standard Drive folder hierarchy and save folder and file identifiers. |
| Workflow | Use a defined list of hiring statuses. | Apply dropdown validation and controlled status actions. |
| Interviews | Create calendar events, invite participants, and retain event IDs. | Connect the candidate record to a separate Interviews sheet and shared calendar. |
| Communications | Send acknowledgements, scheduling messages, reminders, rejection messages, and approved offers. | Use approved Gmail templates and idempotency checks that prevent duplicate sends. |
| Approvals | Keep offer approval human-controlled. | Require an authorized approver to update a protected approval field. |
| Exceptions | Expose incomplete records and failed automation. | Record an exception type, retry count, error message, and error-log entry. |
| Reporting | Report pipeline volume, ownership, aging, interviews, and failures. | Use filtered Sheets views, formulas, and a dashboard sheet. |
| Auditability | Record important automation and communication events. | Write timestamped entries to an Audit Log sheet. |
| Human control | Prevent automation from selecting or rejecting candidates independently. | Require authorized employees to make stage and communication decisions. |
Implementation Approaches Considered
| Approach | Connected tools | Effort | Customization | Main limitation |
|---|---|---|---|---|
| More disciplined manual process | Gmail, Sheets, Drive, Calendar | Low | Low | Reminders, file handling, and status reconciliation still depend on employees. |
| Google Workspace with Apps Script | Forms, Sheets, Drive, Gmail, Calendar, Apps Script | Moderate | High | Requires script ownership, monitoring, and awareness of Google Workspace quotas. |
| Airtable-based tracker | Airtable, forms, email automation, Drive | Moderate | High | Adds another system, permission model, and recurring subscription. |
| Dedicated applicant tracking platform | ATS, email, calendar, job boards, document storage | Moderate to high | Varies | May be more functionality and cost than the current recruiting volume requires. |
A more disciplined manual process
The existing spreadsheet could have been improved with required columns, documented stage definitions, and recurring calendar reminders. This would reduce inconsistency, but it would not remove duplicate entry or connect source messages, folders, emails, and interviews.
Google Workspace with Apps Script
This option retained familiar tools and added an automation layer. It could create records and folders, send messages, create calendar events, enforce workflow rules, and log failures without moving HR data into another business application.
Airtable
Airtable would provide stronger relational views and a more application-like interface than a spreadsheet. It remained a viable alternative, particularly if the business wanted richer forms and record interfaces. It was not selected because the firm wanted to keep candidate records within its existing Google Workspace administration and avoid another permission and licensing model.
A dedicated applicant tracking platform
A dedicated platform would be the strongest option for job-board syndication, candidate portals, high-volume hiring, configurable interview plans, compliance reporting, and vendor support. At 60 applications per month and a small HR team, Cedarline Consulting first needed a controlled internal workflow rather than a larger recruiting platform.
The Selected Solution
The selected implementation used Google Sheets as the system of record, Google Forms and Gmail as intake channels, Google Drive for candidate documents, Google Apps Script for workflow automation, Gmail for notifications, and Google Calendar for interviews.
| Tool | Responsibility |
|---|---|
| Google Forms | Structured applicant intake from the careers page and external job links. |
| Gmail | Shared mailbox intake, applicant acknowledgements, internal assignments, reminders, rejection messages, and approved offer messages. |
| Google Sheets | Candidate, job, interview, audit, error, and dashboard records. |
| Google Drive | Controlled candidate folders, resume copies, interview documents, and approved offer documents. |
| Google Apps Script | Validation, deduplication, identifiers, folder creation, assignments, status actions, reminders, retries, logging, and optional AI calls. |
| Google Calendar | Shared recruiting calendar, interview invitations, participants, and event identifiers. |
| Optional AI service | Resume extraction and summarization for recruiter review after the core workflow is stable. |
LinkedIn was not scraped, and no unsupported LinkedIn integration was assumed. Where a listing allowed an external application URL, it directed candidates to the Google Form with a prefilled source value. Applications received through email were forwarded to the shared recruiting mailbox using a structured header and then labeled for processing.
The implementation removed routine row creation, folder creation, file naming, acknowledgement emails, assignment notices, standard reminders, and calendar-event reconciliation.
Candidate screening, interview decisions, rejection decisions, offer approval, offer terms, and final hiring decisions remained human-controlled.
System Architecture and Data Flow
- Intake: Google Forms and a labeled Gmail shared mailbox.
- System of record: Google Sheets containing Candidates, Jobs, Interviews, Audit Log, Errors, and Dashboard sheets.
- Automation layer: Google Apps Script installed triggers and scheduled functions.
- Document storage: Google Drive candidate folders with Resume, Interviews, and Offer subfolders.
- Notifications: Gmail messages and Google Calendar invitations.
- Reporting: Google Sheets filtered views, formulas, and charts.
- AI layer: Optional resume extraction through an approved AI API, with recruiter review.
-
Submission: A candidate submits a Google Form or an application email receives the Gmail label
ATS/To Process. The form submit trigger runs immediately, while email intake is polled every 15 minutes. -
Validation: Apps Script validates the candidate name, email address, job code, source, and consent indicator. Missing job or resume information can create a controlled
Needs Informationrecord. An invalid email address is sent to the error queue rather than creating an unusable candidate record. -
Duplicate check: The script first checks the form response or Gmail message ID. It then checks normalized candidate email plus job code against non-terminal candidate records.
-
Job lookup: The job code is matched to the Jobs sheet. The script obtains the job title, recruiter, hiring manager, offer approver, and initial service-level due date.
-
Record and folder creation: The script generates an identifier such as
AP-2026-0042, creates the candidate folder hierarchy, copies resume files, and appends a Candidate row. -
Confirmation and assignment: Gmail sends one acknowledgement to the candidate. The owner is assigned from the Jobs sheet, and the record enters
Recruiter RevieworNeeds Information. -
Status actions: An installed edit trigger detects authorized status changes. It sends internal review notices, scheduling requests, approval requests, rejection messages, or approved offer messages when the relevant prerequisites are satisfied.
-
Interview creation: When a recruiter selects
Interview Scheduled, Apps Script validates the dates and panel, creates a Calendar event, appends an Interviews record, and stores the returned calendar event ID. -
Reminders and escalation: A daily trigger finds overdue candidate actions, upcoming interviews, overdue feedback, pending approvals, and failed automation. It sends reminders and escalates after defined thresholds.
-
Failure path: A failed action updates
Automation Status, incrementsRetry Count, stores a concise error, and appends an Errors record. Retryable status actions are attempted up to three times. Other failures remain visible for manual recovery.
Data Structure
The master spreadsheet contains related sheets rather than placing every type of record in one wide table.
- Candidates
- One row per candidate and job combination.
- Jobs
- One row per approved requisition or open role.
- Interviews
- One row per scheduled interview round. Multiple Interviews rows can reference one Candidate ID.
- Audit Log
- Append-only records of significant workflow and communication actions.
- Errors
- Automation failures requiring retry or manual review.
- Dashboard
- Calculated operational metrics sourced from Candidates and Interviews.
| Field | Type | Required | Source or allowed values | Purpose |
|---|---|---|---|---|
| Candidate ID | Text | Yes | Automation, AP-YYYY-NNNN |
Stable internal key. |
| Created Date | Date and time | Yes | Automation | Measures intake volume and age. |
| Last Updated | Date and time | Yes | Automation | Supports monitoring and reconciliation. |
| Source | Dropdown | Yes | Website, LinkedIn, Email, Referral, Other | Reports application origin. |
| Source Record ID | Text | Yes | Form row or Gmail message ID | Prevents duplicate event processing. |
| Job Code | Text | Yes | Applicant input validated against Jobs | Links candidate to an approved role. |
| Candidate Name | Text | Yes | Applicant | Primary display name. |
| Candidate Email | Email text | Yes | Applicant | Deduplication and communications. |
| Consent | Dropdown | Yes | Yes or Email application | Records the intake privacy acknowledgement path. |
| Resume File IDs | Text list | Conditional | Drive | Machine-readable references to stored resumes. |
| Candidate Folder Link | URL | Yes | Drive automation | Opens the controlled candidate folder. |
| Owner Email | Email text | Yes | Jobs lookup or status assignment | Receives action reminders. |
| Status | Dropdown | Yes | Defined workflow statuses | Controls stage actions and reporting. |
| Priority | Dropdown | Yes | Normal, High, Urgent | Supports queue ordering without allowing AI to decide priority. |
| Due Date | Date | Yes | Automation or authorized user | Drives reminders and escalations. |
| Interview Round | Dropdown | Conditional | Phone Screen, Manager Interview, Panel Interview, Final Interview | Identifies the current interview stage. |
| Calendar Event ID | Text | Conditional | Google Calendar | Prevents duplicate event creation and supports reconciliation. |
| Offer Approval Status | Dropdown | Conditional | Not Required, Pending, Approved, Rejected, More Information | Blocks unapproved offer communications. |
| Communication Approved | Dropdown | Conditional | No or Yes | Prevents accidental rejection or offer emails. |
| Exception Type | Dropdown | No | Missing Resume, Invalid Job, Missing Data, Automation Failure, Duplicate Review | Feeds the manual-review queue. |
| Automation Status | Dropdown | Yes | Pending, Running, Completed, Failed, Manual Review | Exposes integration health. |
| Retry Count | Number | Yes | Automation | Limits automatic recovery attempts. |
| Error Message | Text | No | Automation | Provides a concise recovery reason without exposing credentials. |
| AI Status | Dropdown | No | Not Run, Processing, Review Required, Accepted, Failed | Separates optional AI processing from the core workflow. |
| AI Extracted JSON | Long text | No | Optional AI service | Retains the structured output reviewed by a recruiter. |
| Notes | Long text | No | Authorized HR user | Records internal context. It is never inserted automatically into candidate emails. |
| Field | Validation | Purpose |
|---|---|---|
| Job Code | Unique, required | Primary lookup key. |
| Job Title | Required | Candidate and calendar display value. |
| Department | Required | Reporting and ownership. |
| Hiring Manager Name | Required | Internal communication. |
| Hiring Manager Email | Valid company email | Review and escalation destination. |
| Recruiter Name | Required | Default candidate owner. |
| Recruiter Email | Valid company email | Default action and failure owner. |
| Offer Approver Email | Valid authorized email | Offer approval destination. |
| SLA Business Days | Integer from 1 to 10 | Calculates the initial review due date. |
| Active | Yes or No | Prevents new applications against closed job codes. |
The Interviews sheet uses Candidate ID as a foreign key. Although Google Sheets does not enforce database foreign keys, Apps Script rejects interview creation when the candidate record does not exist.
Workflow Statuses and Ownership
| Status | Meaning and entry condition | Owner | Exit condition | Reminder and escalation |
|---|---|---|---|---|
| Needs Information | Required resume, job, or application detail is missing. | Recruiting coordinator | Missing information is supplied and status is changed to Recruiter Review. | Daily after due date; escalate after two reminders. |
| Recruiter Review | A valid application is ready for initial review. | Recruiter | Advance, hold, reject, or request information. | Daily after the job SLA expires. |
| Hiring Manager Review | Recruiter has recommended manager review. | Hiring manager | Interview requested, rejected, or returned to recruiter. | Daily after due date; HR copied after two reminders. |
| Interview Requested | A human has approved interview scheduling. | Recruiting coordinator | Date, panel, and location are confirmed. | Reminder if no interview is scheduled by the due date. |
| Interview Scheduled | Required interview fields are complete and a Calendar event exists. | Recruiting coordinator | Time trigger moves the current interview to Interview Complete after it ends. | Approximately 24-hour reminder to participants. |
| Interview Complete | The scheduled interview end time has passed. | Hiring manager | Feedback supports another interview, offer review, rejection, or hold. | Daily feedback reminder; escalation after two days. |
| Offer Approval | HR has prepared terms for authorized review. | Offer approver | Approved, rejected, or returned for more information. | Daily reminder after the approval due date. |
| Offer Sent | Approval is recorded, an offer document exists, and communication is authorized. | HR manager | Hired, withdrawn, or returned to negotiation. | Follow-up timing is set manually based on the offer expiry date. |
| On Hold | A human has paused the application. | Recruiter | Authorized user selects the next active stage. | No automated candidate message. |
| Rejected | An authorized employee has made a rejection decision. | Recruiter | Terminal after approved communication is sent. | Exception appears if communication approval is missing. |
| Withdrawn | The candidate has withdrawn. | Recruiter | Terminal. | No reminder. |
| Hired | Final offer acceptance has been confirmed by HR. | HR manager | Terminal and handed to onboarding. | No recruiting reminder. |
| Automation Error | A required workflow action failed after retry attempts. | HR system owner | Error is corrected and the intended status is restored. | Included in the daily failure digest. |
A candidate can move backward when a manager requests more information, an approver returns an offer, or another interview round is required. Rejection, offer approval, and hiring remain explicit human decisions. The automation only executes the approved operational action.
Step-by-Step Implementation
Step 1: Prepare the Accounts and Permissions
- Create or designate a Google Workspace automation account, such as a controlled recruiting operations account. Installable triggers run under the account that creates them.
- Create the shared recruiting spreadsheet and place it in an HR-controlled location. The account running the script needs edit access.
- Create a root Drive folder named
Recruiting Candidates. Record its folder ID from the URL. - Create or identify a shared recruiting calendar. Record the calendar ID from its calendar settings.
- Create a shared mailbox or delegated Gmail account for applications. The automation account must be able to read messages and apply labels in the mailbox where the script runs.
- Create Gmail labels named
ATS/To Process,ATS/Processed, andATS/Error. The setup function can also create them. - Create two test users: one acting as a recruiter and one acting as a hiring manager or offer approver.
- Restrict the production spreadsheet to HR and authorized hiring managers. Interview panel members do not automatically need access to the complete candidate register.
- Use a separate test spreadsheet, test root folder, test calendar, and test mailbox labels during development.
The Apps Script project requests access to Sheets, Drive, Gmail, Calendar, script properties, and external requests if the optional AI function is enabled. Review these permissions before authorization. Google provides official guidance for installable Apps Script triggers.
No service account is required for this design. A dedicated Workspace automation user is generally easier to operate because Gmail and Calendar actions are performed with that user’s delegated authority.
Step 2: Build the Intake
Create a Google Form named Employment Application and connect its responses to the master spreadsheet. Use the following question titles exactly if the supplied script is used without modification.
| Question title | Type | Required | Validation or values |
|---|---|---|---|
| Job Code | Dropdown | Yes | Only active job codes. |
| Full Name | Short answer | Yes | Minimum two visible characters. |
| Short answer | Yes | Email-address validation. | |
| Phone | Short answer | No | Plain text to preserve international formatting. |
| Location | Short answer | No | City, region, or time zone only. |
| Work Authorization | Multiple choice | Yes | Authorized, Sponsorship Required, Prefer Not to Answer. |
| LinkedIn URL | Short answer | No | URL validation. |
| Portfolio URL | Short answer | No | URL validation. |
| Resume Upload | File upload | No | PDF or DOCX, one file, organization-approved size limit. |
| Resume Link | Short answer | No | Alternative for applicants unable to use file upload. |
| Application Source | Dropdown | Yes | Website, LinkedIn, Referral, Other. |
| Consent | Checkbox | Yes | Acknowledgement of the recruiting privacy notice. |
Google Forms file-upload questions require the respondent to sign in with a Google account. That can create friction for external applicants. The form therefore also provides a resume-link field and a recruiting email alternative. If unrestricted public uploads are a firm requirement, a dedicated applicant platform or controlled public form service may be more appropriate.
The confirmation message should state that the application was received, that submission does not guarantee an interview, and how the candidate can request a correction or withdrawal. Do not include internal hiring timelines unless the business can maintain them.
For email intake, publish a plain-text template:
Job Code: CON-2026-01
Candidate Name: Applicant Name
Candidate Email: [email protected]
Source: LinkedIn
Phone: Optional
Location: Optional
Work Authorization: Optional
Resume Link: Optional
Attach the resume when a file is available.
A Gmail filter may label messages sent to a dedicated application alias. LinkedIn-originated messages should only be processed when they contain the candidate’s permitted contact details and comply with the source platform’s terms. The automation does not retrieve protected platform data or bypass access controls.
Step 3: Create the System of Record
Create one spreadsheet containing the following sheets:
CandidatesJobsInterviewsAudit LogErrorsDashboard- The Google Form response sheet created by Forms
The supplied setupAts function creates the controlled sheets, exact headers, dropdown validation, filters, formats, and dashboard formulas. It does not delete existing candidate data.
Populate the Jobs sheet before testing intake. A representative row is:
| Job Code | Job Title | Department | Hiring Manager | Recruiter | SLA | Active |
|---|---|---|---|---|---|---|
| CON-2026-01 | Senior Implementation Consultant | Consulting | Authorized test manager | Authorized test recruiter | 3 business days | Yes |
Candidate IDs are generated from the year and a script-property sequence. Script locking prevents two simultaneous submissions from receiving the same sequence value.
The primary duplicate rule is normalized candidate email plus job code for non-terminal records. This does not prevent the same person from applying to two jobs. A separate source-record check prevents the same form event or Gmail message from being processed twice.
Step 4: Connect the Tools
| Source | Trigger | Destination | Authentication | Returned identifier |
|---|---|---|---|---|
| Google Forms | Installed spreadsheet form-submit trigger | Candidates and Drive | Trigger owner’s Workspace authorization | Form sheet ID and row number |
| Gmail | Scheduled scan of ATS/To Process |
Candidates and Drive | Trigger owner’s Gmail authorization | Gmail message ID |
| Candidates | Installed edit trigger | Gmail and Calendar | Trigger owner’s authorization | Gmail audit action or Calendar event ID |
| Google Calendar | Interview Scheduled status | Interviews and Candidates | Calendar access granted to trigger owner | Calendar event ID |
| Google Drive | Candidate creation | Candidates | Drive folder permissions | Folder IDs, file IDs, and links |
Form field names are mapped by question title rather than fixed response-column positions. This makes the intake less sensitive to form column order, but changing a question title still requires a corresponding code update.
Email intake maps structured Field: Value lines. Direct emails without a structured header use the sender name and sender email, but they enter Needs Information if the script cannot determine the job or resume.
Step 5: Build the Core Automation
Form application automation
- Trigger: Installed spreadsheet form-submit event.
- Conditions: The source response contains a valid name, email, job code, source, and consent value.
- Actions: Check idempotency, check duplicates, look up the job, generate an ID, create folders, copy the resume, append the record, send acknowledgement, and add an audit entry.
- Fields updated: Candidate identity, ownership, status, due date, document IDs, automation status, and timestamps.
- Notification: One applicant acknowledgement.
- Exception: Invalid email goes to Errors. Missing resume or invalid job creates a manual-review record where possible.
Email application automation
- Trigger: Time-based trigger every 15 minutes.
- Conditions: Gmail thread has the
ATS/To Processlabel and contains an unprocessed message. - Actions: Parse structured fields, collect non-inline attachments, create the candidate record, and replace the processing label with
ATS/Processed. - Fields updated: Source is Email or the supplied source value; Source Record ID stores the Gmail message ID.
- Notification: Applicant acknowledgement after successful record creation.
- Exception: Failed threads retain the processing label and receive
ATS/Error.
Status-change automation
- Trigger: Installed edit trigger on the Candidates sheet.
- Conditions: A single authorized status or approval field is changed.
- Actions: Update ownership, validate prerequisites, send the corresponding communication, create an interview, or request approval.
- Fields updated: Status dates, owner, due date, communication timestamp, Calendar event ID, approval status, and automation status.
- Notification: Depends on the stage.
- Exception: Missing prerequisites mark the record Failed and add an Errors row.
Candidate emails use action-specific audit keys such as EMAIL_ACK, EMAIL_REJECTION, and EMAIL_OFFER. Before sending, the script checks whether that candidate and action already appear in the Audit Log. This prevents a trigger retry from sending the same consequential message twice.
Step 6: Add Approvals, Reminders, and Escalations
Offer approval is sequential:
- HR prepares the offer document in the candidate’s Offer folder.
- HR enters the document link and changes the candidate status to
Offer Approval. - Apps Script sets
Offer Approval StatustoPendingand emails the designated approver. - The authorized approver reviews the terms and changes the protected approval field to Approved, Rejected, or More Information.
- An Approved value records the approval date and notifies HR.
- HR verifies the final document, changes
Communication Approvedto Yes, and selectsOffer Sent. - The script verifies all three conditions before emailing the candidate: approval is Approved, the document link exists, and communication is approved.
For higher-risk or high-value roles, a second approval field can be added. Parallel approvals should not be simulated with one field. Add a separate approval record for each approver and require all mandatory records to be Approved.
The default reminder rules are:
- Send an owner reminder when the candidate due date is reached or passed.
- Escalate to the HR manager after two reminders.
- Send an interview reminder approximately 24 hours before the event.
- Request interview feedback after the event ends.
- Escalate missing feedback after two days.
- Remind offer approvers daily after the approval due date.
- Attempt retryable failed status actions up to three times.
If an approver is unavailable, HR changes the Offer Approver Email to an authorized delegate before resending the approval request. Delegation should be recorded in Notes and the Audit Log. The automation must not infer a replacement approver.
Step 7: Add Documents and File Management
The root folder uses the following hierarchy:
Recruiting Candidates/
2026/
AP-2026-0042 - Candidate Name/
Resume/
Interviews/
Offer/
Resume copies use the convention:
AP-2026-0042 - Resume - Original File Name.pdf
Folder names are sanitized to remove characters that make searching or exporting difficult. The Candidate row stores both folder IDs and clickable URLs. File IDs are retained because IDs are more stable than names.
HR owns the candidate root folder. Hiring managers receive only the access required to review candidates assigned to them. Interview panel members may receive a resume link when approved by HR, but they do not automatically receive access to the entire candidate directory.
The script copies form-uploaded files rather than moving them. This preserves the source form response while placing a controlled copy in the candidate folder. Email attachments are created directly in the Resume subfolder.
Failed file copies create an exception while preserving the candidate record. HR can upload the missing document manually, update Resume File IDs and Resume Link, clear the exception, and return the record to Recruiter Review.
Retention and deletion schedules must follow applicable employment, privacy, contractual, and legal requirements. The case study does not prescribe a universal retention period.
Step 8: Add Reporting and Operational Views
The Dashboard sheet contains basic counts, while filtered views in Candidates and Interviews support operational work.
| View | Filter | Owner |
|---|---|---|
| New recruiter work | Status equals Recruiter Review | Recruiter |
| Manager decisions | Status equals Hiring Manager Review | Hiring managers |
| Overdue candidates | Due Date before today and not terminal | Recruiting coordinator |
| Incomplete applications | Status equals Needs Information | Recruiting coordinator |
| Manual review | Exception Type is not blank | HR manager |
| Upcoming interviews | Interview Start is within the next seven days | Recruiting coordinator |
| Feedback outstanding | Interview Feedback Status equals Pending | Hiring manager |
| Offer approvals | Offer Approval Status equals Pending | HR manager |
| Automation failures | Automation Status equals Failed | HR system owner |
| Recently completed | Status is Hired, Rejected, or Withdrawn and Last Updated is within 30 days | HR manager |
Useful calculated metrics include applications by source, candidates by status, median days in current stage, interviews scheduled this week, overdue feedback, offer approval aging, automation failure count, and hires by job.
Google Sheets formulas recalculate automatically. Dashboard ownership belongs to the HR manager, while the technical owner verifies formulas after column changes. A non-zero automation-failure count should trigger daily review rather than waiting for a monthly report.
Step 9: Add Security and Governance Controls
- Give the automation account only the Gmail, Drive, Calendar, and spreadsheet access required for recruiting.
- Protect identifier, source ID, automation, approval, and audit columns against general editing.
- Limit the Candidates sheet to HR and authorized hiring managers.
- Do not use public Drive links for resumes or offers.
- Store API keys in Apps Script Properties, not in cells or source code.
- Review trigger ownership whenever the automation account or HR system owner changes.
- Remove former employees from the spreadsheet, root folder, calendar, and delegated mailbox promptly.
- Keep Audit Log and Errors append-only for ordinary users.
- Avoid collecting protected characteristics unless a documented legal and business requirement exists.
- Do not place medical, background-check, compensation, or identity-verification information in the general candidate sheet.
- Back up the spreadsheet and verify that Drive retention rules cover candidate folders.
- Require human approval for rejection, offers, access exceptions, and final hiring.
If the optional AI function is enabled, approve the provider and data-processing terms before sending resumes. Disable provider-side storage where supported, delete temporary uploaded files, restrict the prompt from considering protected characteristics, and require recruiter review of every result.
Step 10: Deploy and Test
- Copy the production spreadsheet to create a test workbook.
- Create a test Drive root folder and a test recruiting calendar.
- Replace configuration placeholders with test identifiers and emails.
- Run
setupAtsmanually and authorize the requested permissions. - Populate the test Jobs sheet with one active and one inactive job.
- Connect a duplicate test form to the test spreadsheet.
- Run
installTriggersand confirm four installed triggers exist. - Submit sample applications with fictional data and non-sensitive sample resumes.
- Test every status transition, approval path, communication prerequisite, retry path, and duplicate condition.
- Complete user acceptance testing with the recruiting coordinator, HR manager, one hiring manager, and one interviewer.
- Pilot the workflow for one open role before adding other requisitions.
- Compare the Candidates sheet to the form response sheet and labeled Gmail threads each day during the pilot.
- Move approved code and configuration to production, install production triggers, and submit one final synthetic test.
- Document the support owner, backup owner, recovery procedure, and rollback process.
Rollback consists of disabling the installed triggers, retaining the spreadsheet and folders, and temporarily returning to controlled manual processing. Records already created should not be deleted solely because the automation is paused.
Code and Configuration
The script is stored in the master Google Sheet. Open the spreadsheet, open the Apps Script editor from the Extensions menu, replace the default editor content, and paste the complete core script below.
Replace every YOUR_... value. The spreadsheet ID appears between /d/ and /edit in the Sheet URL. Folder and Calendar IDs are available from their respective URLs or settings.
const ATS = Object.freeze({
spreadsheetId: 'YOUR_SPREADSHEET_ID',
rootFolderId: 'YOUR_FOLDER_ID',
calendarId: 'YOUR_SHARED_CALENDAR_ID',
fallbackRecruiterName: 'Recruiting Coordinator',
fallbackRecruiterEmail: 'YOUR_EMAIL_ADDRESS',
hrManagerEmail: 'YOUR_HR_MANAGER_EMAIL',
schedulingUrl: 'YOUR_SCHEDULING_URL',
feedbackUrl: 'YOUR_FEEDBACK_FORM_URL',
companyName: 'Cedarline Consulting',
timeZone: 'YOUR_IANA_TIME_ZONE',
processingLabel: 'ATS/To Process',
processedLabel: 'ATS/Processed',
errorLabel: 'ATS/Error',
maxRetries: 3
});
const CANDIDATE_HEADERS = [
'Candidate ID', 'Created Date', 'Last Updated', 'Source',
'Source Record ID', 'Job Code', 'Job Title', 'Candidate Name',
'Candidate Email', 'Candidate Phone', 'Location',
'Work Authorization', 'LinkedIn URL', 'Portfolio URL', 'Consent',
'Resume File IDs', 'Resume Link', 'Candidate Folder ID',
'Candidate Folder Link', 'Owner Name', 'Owner Email',
'Hiring Manager Email', 'Status', 'Status Changed Date', 'Priority',
'Due Date', 'Interview Round', 'Interview Start', 'Interview End',
'Interview Time Zone', 'Interview Panel Emails', 'Interview Mode',
'Interview Location or Link', 'Calendar Event ID',
'Interview Feedback Status', 'Offer Approver Email',
'Offer Approval Status', 'Offer Approval Date', 'Offer Document Link',
'Communication Approved', 'Last Candidate Email', 'Reminder Count',
'Last Reminder Date', 'Exception Type', 'Automation Status',
'Last Automation Run', 'Retry Count', 'Error Message', 'AI Status',
'AI Confidence', 'AI Suggested Current Title',
'AI Suggested Years Experience', 'AI Suggested Skills', 'AI Summary',
'AI Extracted JSON', 'Notes'
];
const JOB_HEADERS = [
'Job Code', 'Job Title', 'Department', 'Hiring Manager Name',
'Hiring Manager Email', 'Recruiter Name', 'Recruiter Email',
'Offer Approver Email', 'SLA Business Days', 'Active'
];
const INTERVIEW_HEADERS = [
'Interview ID', 'Candidate ID', 'Round', 'Start', 'End', 'Time Zone',
'Panel Emails', 'Mode', 'Location or Link', 'Calendar Event ID',
'Feedback Status', 'Feedback Due', 'Created Date',
'Last Reminder Date', 'Reminder Count', 'Notes'
];
const AUDIT_HEADERS = [
'Timestamp', 'Candidate ID', 'Action', 'Actor', 'Details'
];
const ERROR_HEADERS = [
'Timestamp', 'Candidate ID', 'Source Record ID', 'Function',
'Error Message', 'Retry Count', 'Status', 'Resolved Date'
];
const STATUSES = [
'Needs Information', 'Recruiter Review', 'Hiring Manager Review',
'Interview Requested', 'Interview Scheduled', 'Interview Complete',
'Offer Approval', 'Offer Sent', 'On Hold', 'Rejected', 'Withdrawn',
'Hired', 'Automation Error'
];
const TERMINAL_STATUSES = ['Rejected', 'Withdrawn', 'Hired'];
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Applicant Tracking')
.addItem('Set up sheets', 'setupAts')
.addItem('Install triggers', 'installTriggers')
.addItem('Process labeled email now', 'processLabeledApplications')
.addItem('Run reminders now', 'sendDailyReminders')
.addSeparator()
.addItem('Extract resume for active row', 'runAiForActiveRow')
.addToUi();
}
function setupAts() {
validateConfiguration_(false);
const book = getBook_();
const candidates = ensureSheet_(book, 'Candidates', CANDIDATE_HEADERS);
const jobs = ensureSheet_(book, 'Jobs', JOB_HEADERS);
const interviews = ensureSheet_(book, 'Interviews', INTERVIEW_HEADERS);
ensureSheet_(book, 'Audit Log', AUDIT_HEADERS);
ensureSheet_(book, 'Errors', ERROR_HEADERS);
const dashboard = book.getSheetByName('Dashboard') ||
book.insertSheet('Dashboard');
applyCandidateValidation_(candidates);
applyJobValidation_(jobs);
applyInterviewValidation_(interviews);
buildDashboard_(dashboard);
[ATS.processingLabel, ATS.processedLabel, ATS.errorLabel].forEach(name => {
if (!GmailApp.getUserLabelByName(name)) {
GmailApp.createLabel(name);
}
});
SpreadsheetApp.flush();
console.log('ATS sheets, validation, dashboard, and Gmail labels are ready.');
}
function installTriggers() {
validateConfiguration_(true);
const handlers = [
'handleFormSubmit',
'handleCandidateEdit',
'processLabeledApplications',
'sendDailyReminders'
];
ScriptApp.getProjectTriggers().forEach(trigger => {
if (handlers.includes(trigger.getHandlerFunction())) {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger('handleFormSubmit')
.forSpreadsheet(ATS.spreadsheetId)
.onFormSubmit()
.create();
ScriptApp.newTrigger('handleCandidateEdit')
.forSpreadsheet(ATS.spreadsheetId)
.onEdit()
.create();
ScriptApp.newTrigger('processLabeledApplications')
.timeBased()
.everyMinutes(15)
.create();
ScriptApp.newTrigger('sendDailyReminders')
.timeBased()
.everyDays(1)
.atHour(8)
.create();
console.log('Four production triggers were installed.');
}
function handleFormSubmit(event) {
try {
if (!event || !event.range || !event.namedValues) {
throw new Error('Form event does not contain range and namedValues.');
}
const sourceId = 'FORM:' +
event.range.getSheet().getSheetId() + ':' +
event.range.getRow();
const input = {
source: namedValue_(event.namedValues, 'Application Source') || 'Website',
sourceRecordId: sourceId,
jobCode: namedValue_(event.namedValues, 'Job Code'),
candidateName: namedValue_(event.namedValues, 'Full Name'),
candidateEmail: namedValue_(event.namedValues, 'Email'),
candidatePhone: namedValue_(event.namedValues, 'Phone'),
location: namedValue_(event.namedValues, 'Location'),
workAuthorization: namedValue_(event.namedValues, 'Work Authorization'),
linkedInUrl: namedValue_(event.namedValues, 'LinkedIn URL'),
portfolioUrl: namedValue_(event.namedValues, 'Portfolio URL'),
consent: namedValue_(event.namedValues, 'Consent'),
resumeLink: namedValue_(event.namedValues, 'Resume Link')
};
const uploadValue = namedValue_(event.namedValues, 'Resume Upload');
const driveFileIds = extractDriveIds_(uploadValue);
ingestCandidate_(input, driveFileIds, []);
} catch (error) {
recordError_('', '', 'handleFormSubmit', error, 0);
console.error(error.stack || error);
throw error;
}
}
function processLabeledApplications() {
validateConfiguration_(true);
const processing = GmailApp.getUserLabelByName(ATS.processingLabel);
const processed = GmailApp.getUserLabelByName(ATS.processedLabel);
const errorLabel = GmailApp.getUserLabelByName(ATS.errorLabel);
if (!processing || !processed || !errorLabel) {
throw new Error('Required Gmail labels are missing. Run setupAts first.');
}
const threads = processing.getThreads(0, 25);
threads.forEach(thread => {
let handled = false;
try {
const messages = thread.getMessages();
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
const sourceId = 'GMAIL:' + message.getId();
if (findCandidateRowByField_('Source Record ID', sourceId)) {
handled = true;
break;
}
const parsed = parseApplicationEmail_(message);
const attachments = message.getAttachments({
includeInlineImages: false,
includeAttachments: true
});
ingestCandidate_(parsed, [], attachments);
handled = true;
break;
}
if (handled) {
thread.removeLabel(processing);
thread.removeLabel(errorLabel);
thread.addLabel(processed);
}
} catch (error) {
thread.addLabel(errorLabel);
recordError_('', 'GMAIL_THREAD:' + thread.getId(),
'processLabeledApplications', error, 0);
console.error(error.stack || error);
}
});
}
function ingestCandidate_(input, driveFileIds, attachmentBlobs) {
validateCandidateInput_(input);
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const existingSourceRow = findCandidateRowByField_(
'Source Record ID', input.sourceRecordId
);
if (existingSourceRow) {
console.log('Source event already processed: ' + input.sourceRecordId);
return getCandidateRecord_(existingSourceRow);
}
const duplicateRow = findActiveDuplicate_(
input.candidateEmail, input.jobCode
);
if (duplicateRow) {
const duplicate = getCandidateRecord_(duplicateRow);
audit_(duplicate['Candidate ID'], 'DUPLICATE_SUPPRESSED',
'Source ' + input.sourceRecordId +
' matched email and job code.');
return duplicate;
}
const job = findJob_(input.jobCode);
const candidateId = nextCandidateId_();
const folderSet = createCandidateFolders_(
candidateId, input.candidateName
);
const storedFiles = storeResumes_(
candidateId,
folderSet.resumeFolder,
driveFileIds || [],
attachmentBlobs || []
);
const hasResume = storedFiles.fileIds.length > 0 ||
Boolean(input.resumeLink);
const jobIsValid = Boolean(job);
const status = jobIsValid && hasResume ?
'Recruiter Review' : 'Needs Information';
const exceptionParts = [];
if (!jobIsValid) exceptionParts.push('Invalid Job');
if (!hasResume) exceptionParts.push('Missing Resume');
const ownerName = job ?
job['Recruiter Name'] : ATS.fallbackRecruiterName;
const ownerEmail = job ?
job['Recruiter Email'] : ATS.fallbackRecruiterEmail;
const slaDays = job ?
Number(job['SLA Business Days']) || 3 : 1;
const now = new Date();
const record = {
'Candidate ID': candidateId,
'Created Date': now,
'Last Updated': now,
'Source': input.source,
'Source Record ID': input.sourceRecordId,
'Job Code': input.jobCode,
'Job Title': job ? job['Job Title'] : '',
'Candidate Name': input.candidateName,
'Candidate Email': normalizeEmail_(input.candidateEmail),
'Candidate Phone': input.candidatePhone || '',
'Location': input.location || '',
'Work Authorization': input.workAuthorization || '',
'LinkedIn URL': input.linkedInUrl || '',
'Portfolio URL': input.portfolioUrl || '',
'Consent': input.consent,
'Resume File IDs': storedFiles.fileIds.join(','),
'Resume Link': storedFiles.firstLink || input.resumeLink || '',
'Candidate Folder ID': folderSet.candidateFolder.getId(),
'Candidate Folder Link': folderSet.candidateFolder.getUrl(),
'Owner Name': ownerName,
'Owner Email': ownerEmail,
'Hiring Manager Email': job ? job['Hiring Manager Email'] : '',
'Status': status,
'Status Changed Date': now,
'Priority': 'Normal',
'Due Date': addBusinessDays_(now, slaDays),
'Interview Feedback Status': 'Not Required',
'Offer Approver Email': job ? job['Offer Approver Email'] : '',
'Offer Approval Status': 'Not Required',
'Communication Approved': 'No',
'Reminder Count': 0,
'Exception Type': exceptionParts.join(', '),
'Automation Status': 'Running',
'Last Automation Run': now,
'Retry Count': 0,
'AI Status': 'Not Run',
'Notes': storedFiles.errors.join(' | ')
};
const sheet = getBook_().getSheetByName('Candidates');
sheet.appendRow(objectToRow_(record, CANDIDATE_HEADERS));
const row = sheet.getLastRow();
audit_(candidateId, 'CANDIDATE_CREATED',
'Created from ' + input.source + ' with status ' + status + '.');
try {
sendCandidateOnce_(
row,
'EMAIL_ACK',
'Application received for ' + (record['Job Title'] || input.jobCode),
'Thank you for applying to ' + ATS.companyName +
'. We received your application for ' +
(record['Job Title'] || input.jobCode) +
'. Our recruiting team will review the information provided.'
);
updateCandidateFields_(row, {
'Automation Status': exceptionParts.length ?
'Manual Review' : 'Completed',
'Last Automation Run': new Date(),
'Error Message': ''
});
} catch (error) {
markCandidateFailure_(row, 'ingestCandidate_', error);
}
return getCandidateRecord_(row);
} finally {
lock.releaseLock();
}
}
function handleCandidateEdit(event) {
if (!event || !event.range || event.range.getNumRows() !== 1 ||
event.range.getNumColumns() !== 1) {
return;
}
const sheet = event.range.getSheet();
const row = event.range.getRow();
if (row < 2) return;
if (sheet.getName() === 'Candidates') {
handleCandidateSheetEdit_(event, row);
} else if (sheet.getName() === 'Interviews') {
handleInterviewSheetEdit_(event, row);
}
}
function handleCandidateSheetEdit_(event, row) {
const sheet = event.range.getSheet();
const field = sheet.getRange(1, event.range.getColumn()).getValue();
if (!['Status', 'Offer Approval Status',
'Communication Approved'].includes(field)) {
return;
}
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
updateCandidateFields_(row, {
'Last Updated': new Date(),
'Last Automation Run': new Date()
});
if (field === 'Status') {
const status = String(event.value || '').trim();
if (!STATUSES.includes(status)) {
throw new Error('Invalid candidate status: ' + status);
}
updateCandidateFields_(row, {
'Status Changed Date': new Date(),
'Automation Status': 'Running',
'Error Message': ''
});
assignOwnerForStatus_(row, status);
processStatusChange_(row, status);
updateCandidateFields_(row, {
'Automation Status': 'Completed',
'Last Automation Run': new Date(),
'Error Message': ''
});
}
if (field === 'Offer Approval Status') {
processOfferApprovalChange_(row, String(event.value || ''));
}
if (field === 'Communication Approved' &&
String(event.value || '') === 'Yes') {
const record = getCandidateRecord_(row);
if (['Rejected', 'Offer Sent'].includes(record['Status'])) {
processStatusChange_(row, record['Status']);
}
}
} catch (error) {
markCandidateFailure_(row, 'handleCandidateEdit', error);
console.error(error.stack || error);
} finally {
lock.releaseLock();
}
}
function handleInterviewSheetEdit_(event, row) {
const field = event.range.getSheet()
.getRange(1, event.range.getColumn()).getValue();
if (field !== 'Feedback Status') return;
const interview = recordFromRow_('Interviews', row, INTERVIEW_HEADERS);
const candidateRow = findCandidateRowByField_(
'Candidate ID', interview['Candidate ID']
);
if (candidateRow) {
updateCandidateFields_(candidateRow, {
'Interview Feedback Status': event.value || '',
'Last Updated': new Date()
});
audit_(interview['Candidate ID'], 'FEEDBACK_STATUS_UPDATED',
'Interview ' + interview['Interview ID'] +
' changed to ' + event.value + '.');
}
}
function processStatusChange_(row, status) {
const record = getCandidateRecord_(row);
switch (status) {
case 'Needs Information':
notifyInternalOnce_(
record['Candidate ID'],
'NEEDS_INFORMATION_NOTICE',
record['Owner Email'],
'Application requires information: ' + record['Candidate ID'],
'Review the missing fields or documents for ' +
record['Candidate Name'] + '.'
);
break;
case 'Hiring Manager Review':
requireEmail_(record['Hiring Manager Email'], 'Hiring manager email');
notifyInternalOnce_(
record['Candidate ID'],
'HIRING_MANAGER_REVIEW:' +
dateKey_(record['Status Changed Date']),
record['Hiring Manager Email'],
'Candidate review requested: ' + record['Candidate Name'],
'Candidate ' + record['Candidate ID'] +
' is ready for hiring manager review. Candidate folder: ' +
record['Candidate Folder Link']
);
break;
case 'Interview Requested':
sendInterviewRequest_(row, record);
break;
case 'Interview Scheduled':
scheduleInterview_(row, record);
break;
case 'Interview Complete':
updateCandidateFields_(row, {
'Interview Feedback Status': 'Pending'
});
notifyInterviewPanel_(record, 'Interview feedback requested');
break;
case 'Offer Approval':
requestOfferApproval_(row, record);
break;
case 'Offer Sent':
sendOffer_(row, record);
break;
case 'Rejected':
sendRejection_(row, record);
break;
case 'Hired':
notifyInternalOnce_(
record['Candidate ID'],
'HIRED_NOTICE',
joinEmails_([
ATS.hrManagerEmail,
record['Hiring Manager Email'],
record['Owner Email']
]),
'Hire confirmed: ' + record['Candidate Name'],
'HR recorded ' + record['Candidate Name'] +
' as hired for ' + record['Job Title'] + '.'
);
break;
default:
break;
}
audit_(record['Candidate ID'], 'STATUS_PROCESSED',
'Processed status ' + status + '.');
}
function sendInterviewRequest_(row, record) {
let instruction = 'Please reply with your availability.';
if (!isPlaceholder_(ATS.schedulingUrl)) {
instruction = 'Choose an available time using ' + ATS.schedulingUrl;
}
sendCandidateOnce_(
row,
'INTERVIEW_REQUEST:' + dateKey_(record['Status Changed Date']),
'Interview availability for ' + record['Job Title'],
'We would like to arrange an interview regarding the ' +
record['Job Title'] + ' opportunity. ' + instruction
);
}
function scheduleInterview_(row, record) {
const start = record['Interview Start'];
const end = record['Interview End'];
const round = String(record['Interview Round'] || '').trim();
const panel = String(record['Interview Panel Emails'] || '').trim();
if (!(start instanceof Date) || !(end instanceof Date) ||
end.getTime() <= start.getTime()) {
throw new Error('Valid interview start and end values are required.');
}
if (!round) throw new Error('Interview Round is required.');
if (!panel) throw new Error('At least one panel email is required.');
const interviewId = record['Candidate ID'] + '-INT-' +
Utilities.formatDate(start, ATS.timeZone, 'yyyyMMddHHmm');
const existing = findInterviewById_(interviewId);
if (existing) {
updateCandidateFields_(row, {
'Calendar Event ID': existing['Calendar Event ID'],
'Interview Feedback Status': existing['Feedback Status']
});
return;
}
const calendar = CalendarApp.getCalendarById(ATS.calendarId);
if (!calendar) {
throw new Error('Configured recruiting calendar was not found.');
}
const guestEmails = joinEmails_(
[record['Candidate Email']].concat(splitEmails_(panel))
);
const event = calendar.createEvent(
record['Interview Round'] + ': ' + record['Candidate Name'],
start,
end,
{
description:
'Candidate ID: ' + record['Candidate ID'] + '\n' +
'Role: ' + record['Job Title'] + '\n' +
'Candidate folder: ' + record['Candidate Folder Link'],
location: record['Interview Location or Link'] || '',
guests: guestEmails,
sendInvites: true
}
);
const feedbackDue = new Date(end.getTime() + 24 * 60 * 60 * 1000);
const interviewRecord = {
'Interview ID': interviewId,
'Candidate ID': record['Candidate ID'],
'Round': round,
'Start': start,
'End': end,
'Time Zone': record['Interview Time Zone'] || ATS.timeZone,
'Panel Emails': panel,
'Mode': record['Interview Mode'] || '',
'Location or Link': record['Interview Location or Link'] || '',
'Calendar Event ID': event.getId(),
'Feedback Status': 'Pending',
'Feedback Due': feedbackDue,
'Created Date': new Date(),
'Reminder Count': 0
};
getBook_().getSheetByName('Interviews')
.appendRow(objectToRow_(interviewRecord, INTERVIEW_HEADERS));
updateCandidateFields_(row, {
'Calendar Event ID': event.getId(),
'Interview Feedback Status': 'Pending',
'Due Date': feedbackDue
});
audit_(record['Candidate ID'], 'CALENDAR_CREATED:' + interviewId,
'Created calendar event ' + event.getId() + '.');
}
function requestOfferApproval_(row, record) {
requireEmail_(record['Offer Approver Email'], 'Offer approver email');
if (!record['Offer Document Link']) {
throw new Error('Offer Document Link is required before approval.');
}
updateCandidateFields_(row, {
'Offer Approval Status': 'Pending',
'Due Date': addBusinessDays_(new Date(), 2)
});
notifyInternalOnce_(
record['Candidate ID'],
'OFFER_APPROVAL_REQUEST:' + dateKey_(record['Status Changed Date']),
record['Offer Approver Email'],
'Offer approval requested: ' + record['Candidate Name'],
'Review the proposed offer for ' + record['Candidate Name'] +
'. Document: ' + record['Offer Document Link'] +
'. Record the decision in the protected approval field.'
);
}
function processOfferApprovalChange_(row, value) {
const allowed = [
'Not Required', 'Pending', 'Approved',
'Rejected', 'More Information'
];
if (!allowed.includes(value)) {
throw new Error('Invalid offer approval value: ' + value);
}
const record = getCandidateRecord_(row);
if (value === 'Approved') {
updateCandidateFields_(row, {
'Offer Approval Date': new Date(),
'Automation Status': 'Completed',
'Error Message': ''
});
notifyInternalOnce_(
record['Candidate ID'],
'OFFER_APPROVED',
record['Owner Email'],
'Offer approved: ' + record['Candidate Name'],
'The authorized approver recorded approval. Verify the final document ' +
'and candidate communication before changing status to Offer Sent.'
);
}
if (value === 'Rejected' || value === 'More Information') {
notifyInternalOnce_(
record['Candidate ID'],
'OFFER_RETURNED:' + value + ':' + dateKey_(new Date()),
record['Owner Email'],
'Offer requires HR action: ' + record['Candidate Name'],
'The offer approval status is ' + value + '.'
);
}
audit_(record['Candidate ID'], 'OFFER_APPROVAL_' + value.toUpperCase()
.replace(/\s+/g, '_'), 'Approval field updated.');
}
function sendOffer_(row, record) {
if (record['Offer Approval Status'] !== 'Approved') {
throw new Error('Offer approval must be Approved.');
}
if (record['Communication Approved'] !== 'Yes') {
throw new Error('Communication Approved must be Yes.');
}
if (!record['Offer Document Link']) {
throw new Error('Offer Document Link is required.');
}
sendCandidateOnce_(
row,
'EMAIL_OFFER',
'Offer information from ' + ATS.companyName,
'We are pleased to provide your approved offer information for the ' +
record['Job Title'] + ' opportunity. Review the document here: ' +
record['Offer Document Link'] +
'. Please contact your recruiter with questions.'
);
}
function sendRejection_(row, record) {
if (record['Communication Approved'] !== 'Yes') {
throw new Error(
'Communication Approved must be Yes before rejection email.'
);
}
sendCandidateOnce_(
row,
'EMAIL_REJECTION',
'Update regarding your application',
'Thank you for your interest in the ' + record['Job Title'] +
' opportunity at ' + ATS.companyName +
'. We have decided not to move forward with your application for ' +
'this role. We appreciate the time you invested in the process.'
);
}
function sendDailyReminders() {
validateConfiguration_(true);
processCandidateReminders_();
processInterviewReminders_();
}
function processCandidateReminders_() {
const sheet = getBook_().getSheetByName('Candidates');
if (sheet.getLastRow() < 2) return;
const values = sheet.getRange(
2, 1, sheet.getLastRow() - 1, CANDIDATE_HEADERS.length
).getValues();
const today = startOfDay_(new Date());
values.forEach((rowValues, offset) => {
const row = offset + 2;
const record = rowToObject_(rowValues, CANDIDATE_HEADERS);
if (!record['Candidate ID'] ||
TERMINAL_STATUSES.includes(record['Status'])) {
return;
}
try {
if (record['Automation Status'] === 'Failed' &&
Number(record['Retry Count'] || 0) < ATS.maxRetries) {
processStatusChange_(row, record['Status']);
updateCandidateFields_(row, {
'Automation Status': 'Completed',
'Last Automation Run': new Date(),
'Error Message': ''
});
}
const dueDate = record['Due Date'];
const lastReminder = record['Last Reminder Date'];
if (dueDate instanceof Date &&
startOfDay_(dueDate).getTime() <= today.getTime() &&
!sameDay_(lastReminder, today)) {
const count = Number(record['Reminder Count'] || 0) + 1;
const recipients = count > 2 ?
joinEmails_([record['Owner Email'], ATS.hrManagerEmail]) :
record['Owner Email'];
requireEmail_(recipients, 'Reminder recipient');
GmailApp.sendEmail(
recipients,
'Candidate action due: ' + record['Candidate ID'],
'Action is due for ' + record['Candidate Name'] +
', status ' + record['Status'] + '.'
);
updateCandidateFields_(row, {
'Reminder Count': count,
'Last Reminder Date': new Date()
});
audit_(record['Candidate ID'], 'OWNER_REMINDER',
'Reminder ' + count + ' sent for ' + record['Status'] + '.');
}
} catch (error) {
markCandidateFailure_(row, 'processCandidateReminders_', error);
}
});
}
function processInterviewReminders_() {
const sheet = getBook_().getSheetByName('Interviews');
if (sheet.getLastRow() < 2) return;
const values = sheet.getRange(
2, 1, sheet.getLastRow() - 1, INTERVIEW_HEADERS.length
).getValues();
const now = new Date();
values.forEach((rowValues, offset) => {
const row = offset + 2;
const interview = rowToObject_(rowValues, INTERVIEW_HEADERS);
if (!interview['Interview ID']) return;
const candidateRow = findCandidateRowByField_(
'Candidate ID', interview['Candidate ID']
);
if (!candidateRow) return;
const candidate = getCandidateRecord_(candidateRow);
const start = interview['Start'];
const end = interview['End'];
const lastReminder = interview['Last Reminder Date'];
const hoursUntilStart = start instanceof Date ?
(start.getTime() - now.getTime()) / 3600000 : null;
try {
if (hoursUntilStart !== null &&
hoursUntilStart >= 20 && hoursUntilStart <= 28 &&
!sameDay_(lastReminder, now)) {
const recipients = joinEmails_(
[candidate['Candidate Email']]
.concat(splitEmails_(interview['Panel Emails']))
);
GmailApp.sendEmail(
recipients,
'Interview reminder: ' + candidate['Job Title'],
'Reminder for the ' + interview['Round'] +
' interview scheduled for ' +
Utilities.formatDate(start, ATS.timeZone, 'yyyy-MM-dd HH:mm') +
' ' + ATS.timeZone + '.'
);
updateInterviewFields_(row, {
'Last Reminder Date': now,
'Reminder Count': Number(interview['Reminder Count'] || 0) + 1
});
audit_(candidate['Candidate ID'],
'INTERVIEW_REMINDER:' + interview['Interview ID'],
'Sent approximately 24-hour reminder.');
}
if (end instanceof Date && end.getTime() < now.getTime() &&
interview['Feedback Status'] === 'Pending' &&
!sameDay_(lastReminder, now)) {
notifyInterviewPanel_(candidate,
'Feedback requested for ' + interview['Round']);
updateInterviewFields_(row, {
'Last Reminder Date': now,
'Reminder Count': Number(interview['Reminder Count'] || 0) + 1
});
if (candidate['Calendar Event ID'] ===
interview['Calendar Event ID'] &&
candidate['Status'] === 'Interview Scheduled') {
updateCandidateFields_(candidateRow, {
'Status': 'Interview Complete',
'Status Changed Date': now,
'Interview Feedback Status': 'Pending',
'Owner Email': candidate['Hiring Manager Email'],
'Due Date': interview['Feedback Due'],
'Last Updated': now
});
}
}
} catch (error) {
recordError_(candidate['Candidate ID'], interview['Interview ID'],
'processInterviewReminders_', error,
Number(interview['Reminder Count'] || 0));
}
});
}
function notifyInterviewPanel_(record, subject) {
const recipients = joinEmails_(
splitEmails_(record['Interview Panel Emails'])
.concat([record['Hiring Manager Email'], record['Owner Email']])
);
requireEmail_(recipients, 'Interview feedback recipients');
let body = 'Please submit interview feedback for ' +
record['Candidate ID'] + '.';
if (!isPlaceholder_(ATS.feedbackUrl)) {
body += ' Feedback form: ' + ATS.feedbackUrl;
}
GmailApp.sendEmail(recipients, subject, body);
}
function assignOwnerForStatus_(row, status) {
const record = getCandidateRecord_(row);
const job = findJob_(record['Job Code']);
if (!job) return;
let ownerName = job['Recruiter Name'];
let ownerEmail = job['Recruiter Email'];
if (['Hiring Manager Review', 'Interview Complete'].includes(status)) {
ownerName = job['Hiring Manager Name'];
ownerEmail = job['Hiring Manager Email'];
} else if (status === 'Offer Approval') {
ownerName = 'Offer Approver';
ownerEmail = job['Offer Approver Email'];
}
updateCandidateFields_(row, {
'Owner Name': ownerName,
'Owner Email': ownerEmail,
'Due Date': addBusinessDays_(new Date(), 2)
});
}
function sendCandidateOnce_(row, action, subject, body) {
const record = getCandidateRecord_(row);
if (hasAuditAction_(record['Candidate ID'], action)) return;
requireEmail_(record['Candidate Email'], 'Candidate email');
GmailApp.sendEmail(record['Candidate Email'], subject, body, {
name: ATS.companyName + ' Recruiting',
replyTo: ATS.fallbackRecruiterEmail
});
updateCandidateFields_(row, {
'Last Candidate Email': new Date()
});
audit_(record['Candidate ID'], action,
'Sent candidate email with subject: ' + subject);
}
function notifyInternalOnce_(candidateId, action, recipients, subject, body) {
if (hasAuditAction_(candidateId, action)) return;
requireEmail_(recipients, 'Internal notification recipient');
GmailApp.sendEmail(recipients, subject, body);
audit_(candidateId, action, 'Sent internal email: ' + subject);
}
function parseApplicationEmail_(message) {
const body = message.getPlainBody();
const fromEmail = extractEmail_(message.getFrom());
const fromName = extractDisplayName_(message.getFrom());
const subject = message.getSubject();
return {
source: lineValue_(body, 'Source') ||
(/linkedin/i.test(subject) ? 'LinkedIn' : 'Email'),
sourceRecordId: 'GMAIL:' + message.getId(),
jobCode: lineValue_(body, 'Job Code'),
candidateName: lineValue_(body, 'Candidate Name') || fromName,
candidateEmail: lineValue_(body, 'Candidate Email') || fromEmail,
candidatePhone: lineValue_(body, 'Phone'),
location: lineValue_(body, 'Location'),
workAuthorization: lineValue_(body, 'Work Authorization'),
linkedInUrl: lineValue_(body, 'LinkedIn URL'),
portfolioUrl: lineValue_(body, 'Portfolio URL'),
resumeLink: lineValue_(body, 'Resume Link'),
consent: 'Email application'
};
}
function createCandidateFolders_(candidateId, candidateName) {
const root = DriveApp.getFolderById(ATS.rootFolderId);
const year = candidateId.split('-')[1];
const yearFolder = getOrCreateFolder_(root, year);
const safeName = sanitizeFileName_(candidateName);
const candidateFolder = getOrCreateFolder_(
yearFolder, candidateId + ' - ' + safeName
);
return {
candidateFolder: candidateFolder,
resumeFolder: getOrCreateFolder_(candidateFolder, 'Resume'),
interviewFolder: getOrCreateFolder_(candidateFolder, 'Interviews'),
offerFolder: getOrCreateFolder_(candidateFolder, 'Offer')
};
}
function storeResumes_(candidateId, folder, driveFileIds, blobs) {
const fileIds = [];
const links = [];
const errors = [];
driveFileIds.forEach(fileId => {
try {
const source = DriveApp.getFileById(fileId);
const copy = source.makeCopy(
candidateId + ' - Resume - ' +
sanitizeFileName_(source.getName()),
folder
);
fileIds.push(copy.getId());
links.push(copy.getUrl());
} catch (error) {
errors.push('Drive copy failed for ' + fileId + ': ' + error.message);
}
});
blobs.forEach((blob, index) => {
try {
const originalName = blob.getName() || ('Attachment-' + (index + 1));
const file = folder.createFile(blob)
.setName(candidateId + ' - Resume - ' +
sanitizeFileName_(originalName));
fileIds.push(file.getId());
links.push(file.getUrl());
} catch (error) {
errors.push('Attachment save failed: ' + error.message);
}
});
return {
fileIds: fileIds,
firstLink: links[0] || '',
errors: errors
};
}
function findJob_(jobCode) {
const sheet = getBook_().getSheetByName('Jobs');
if (!sheet || sheet.getLastRow() < 2) return null;
const values = sheet.getRange(
2, 1, sheet.getLastRow() - 1, JOB_HEADERS.length
).getValues();
for (let index = 0; index < values.length; index += 1) {
const record = rowToObject_(values[index], JOB_HEADERS);
if (String(record['Job Code']).trim().toLowerCase() ===
String(jobCode).trim().toLowerCase() &&
String(record['Active']).trim().toLowerCase() === 'yes') {
return record;
}
}
return null;
}
function findActiveDuplicate_(email, jobCode) {
const sheet = getBook_().getSheetByName('Candidates');
if (!sheet || sheet.getLastRow() < 2) return 0;
const map = headerMap_(CANDIDATE_HEADERS);
const values = sheet.getRange(
2, 1, sheet.getLastRow() - 1, CANDIDATE_HEADERS.length
).getValues();
const targetEmail = normalizeEmail_(email);
const targetJob = String(jobCode).trim().toLowerCase();
for (let index = 0; index < values.length; index += 1) {
const row = values[index];
const rowEmail = normalizeEmail_(row[map['Candidate Email']]);
const rowJob = String(row[map['Job Code']]).trim().toLowerCase();
const status = String(row[map['Status']]);
if (rowEmail === targetEmail && rowJob === targetJob &&
!TERMINAL_STATUSES.includes(status)) {
return index + 2;
}
}
return 0;
}
function findCandidateRowByField_(field, value) {
const sheet = getBook_().getSheetByName('Candidates');
if (!sheet || sheet.getLastRow() < 2) return 0;
const column = CANDIDATE_HEADERS.indexOf(field) + 1;
if (!column) throw new Error('Unknown candidate field: ' + field);
const finder = sheet.getRange(2, column, sheet.getLastRow() - 1, 1)
.createTextFinder(String(value))
.matchEntireCell(true)
.findNext();
return finder ? finder.getRow() : 0;
}
function findInterviewById_(interviewId) {
const sheet = getBook_().getSheetByName('Interviews');
if (!sheet || sheet.getLastRow() < 2) return null;
const finder = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
.createTextFinder(interviewId)
.matchEntireCell(true)
.findNext();
return finder ?
recordFromRow_('Interviews', finder.getRow(), INTERVIEW_HEADERS) :
null;
}
function nextCandidateId_() {
const year = Utilities.formatDate(new Date(), ATS.timeZone, 'yyyy');
const key = 'ATS_SEQUENCE_' + year;
const properties = PropertiesService.getScriptProperties();
const next = Number(properties.getProperty(key) || 0) + 1;
properties.setProperty(key, String(next));
return 'AP-' + year + '-' + String(next).padStart(4, '0');
}
function updateCandidateFields_(row, updates) {
updateFields_('Candidates', row, CANDIDATE_HEADERS, updates);
}
function updateInterviewFields_(row, updates) {
updateFields_('Interviews', row, INTERVIEW_HEADERS, updates);
}
function updateFields_(sheetName, row, headers, updates) {
const sheet = getBook_().getSheetByName(sheetName);
Object.keys(updates).forEach(field => {
const column = headers.indexOf(field) + 1;
if (!column) throw new Error('Unknown field: ' + field);
sheet.getRange(row, column).setValue(updates[field]);
});
}
function getCandidateRecord_(row) {
return recordFromRow_('Candidates', row, CANDIDATE_HEADERS);
}
function recordFromRow_(sheetName, row, headers) {
const values = getBook_().getSheetByName(sheetName)
.getRange(row, 1, 1, headers.length)
.getValues()[0];
return rowToObject_(values, headers);
}
function markCandidateFailure_(row, functionName, error) {
const record = getCandidateRecord_(row);
const retryCount = Number(record['Retry Count'] || 0) + 1;
updateCandidateFields_(row, {
'Automation Status': retryCount >= ATS.maxRetries ?
'Manual Review' : 'Failed',
'Last Automation Run': new Date(),
'Retry Count': retryCount,
'Error Message': String(error.message || error).slice(0, 500),
'Exception Type': 'Automation Failure'
});
recordError_(
record['Candidate ID'],
record['Source Record ID'],
functionName,
error,
retryCount
);
}
function audit_(candidateId, action, details) {
getBook_().getSheetByName('Audit Log').appendRow([
new Date(),
candidateId,
action,
Session.getEffectiveUser().getEmail() || 'automation',
String(details || '').slice(0, 1000)
]);
}
function hasAuditAction_(candidateId, action) {
const sheet = getBook_().getSheetByName('Audit Log');
if (!sheet || sheet.getLastRow() < 2) return false;
const values = sheet.getRange(
2, 1, sheet.getLastRow() - 1, AUDIT_HEADERS.length
).getValues();
return values.some(row =>
String(row[1]) === String(candidateId) &&
String(row[2]) === String(action)
);
}
function recordError_(candidateId, sourceId, functionName, error, retryCount) {
const sheet = getBook_().getSheetByName('Errors');
if (!sheet) {
console.error('Errors sheet is unavailable: ' + error);
return;
}
sheet.appendRow([
new Date(),
candidateId || '',
sourceId || '',
functionName,
String(error.message || error).slice(0, 1000),
retryCount || 0,
'Open',
''
]);
}
function validateCandidateInput_(input) {
if (!input) throw new Error('Candidate input is missing.');
if (!String(input.candidateName || '').trim()) {
throw new Error('Candidate name is required.');
}
requireEmail_(input.candidateEmail, 'Candidate email');
if (!String(input.jobCode || '').trim()) {
throw new Error('Job code is required.');
}
if (!String(input.sourceRecordId || '').trim()) {
throw new Error('Source record ID is required.');
}
const consent = String(input.consent || '').toLowerCase();
if (!consent.includes('yes') &&
consent !== 'email application') {
throw new Error('Consent acknowledgement is required.');
}
}
function requireEmail_(value, label) {
const emails = splitEmails_(value);
if (!emails.length ||
emails.some(email =>
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))) {
throw new Error(label + ' is missing or invalid.');
}
}
function ensureSheet_(book, name, headers) {
const sheet = book.getSheetByName(name) || book.insertSheet(name);
if (sheet.getMaxColumns() < headers.length) {
sheet.insertColumnsAfter(
sheet.getMaxColumns(),
headers.length - sheet.getMaxColumns()
);
}
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
} else {
const existing = sheet.getRange(1, 1, 1, headers.length).getValues()[0];
const mismatch = headers.some((header, index) =>
existing[index] !== header
);
if (mismatch) {
throw new Error('Header mismatch on sheet ' + name +
'. Back up data before changing columns.');
}
}
sheet.setFrozenRows(1);
sheet.getRange(1, 1, 1, headers.length).setFontWeight('bold');
if (!sheet.getFilter() && sheet.getMaxRows() > 1) {
sheet.getRange(1, 1, sheet.getMaxRows(), headers.length).createFilter();
}
return sheet;
}
function applyCandidateValidation_(sheet) {
setListValidation_(sheet, 'Source',
['Website', 'LinkedIn', 'Email', 'Referral', 'Other']);
setListValidation_(sheet, 'Work Authorization',
['Authorized', 'Sponsorship Required', 'Prefer Not to Answer']);
setListValidation_(sheet, 'Status', STATUSES);
setListValidation_(sheet, 'Priority', ['Normal', 'High', 'Urgent']);
setListValidation_(sheet, 'Interview Round',
['Phone Screen', 'Manager Interview', 'Panel Interview',
'Final Interview']);
setListValidation_(sheet, 'Interview Mode',
['Video', 'Phone', 'In Person']);
setListValidation_(sheet, 'Interview Feedback Status',
['Not Required', 'Pending', 'Complete', 'Escalated']);
setListValidation_(sheet, 'Offer Approval Status',
['Not Required', 'Pending', 'Approved', 'Rejected',
'More Information']);
setListValidation_(sheet, 'Communication Approved', ['No', 'Yes']);
setListValidation_(sheet, 'Automation Status',
['Pending', 'Running', 'Completed', 'Failed', 'Manual Review']);
setListValidation_(sheet, 'AI Status',
['Not Run', 'Processing', 'Review Required', 'Accepted', 'Failed']);
formatDateColumns_(sheet, CANDIDATE_HEADERS, [
'Created Date', 'Last Updated', 'Status Changed Date', 'Due Date',
'Interview Start', 'Interview End', 'Offer Approval Date',
'Last Candidate Email', 'Last Reminder Date', 'Last Automation Run'
]);
}
function applyJobValidation_(sheet) {
setListValidation_(sheet, 'Active', ['Yes', 'No'], JOB_HEADERS);
}
function applyInterviewValidation_(sheet) {
setListValidation_(sheet, 'Feedback Status',
['Pending', 'Complete', 'Escalated'], INTERVIEW_HEADERS);
formatDateColumns_(sheet, INTERVIEW_HEADERS,
['Start', 'End', 'Feedback Due', 'Created Date', 'Last Reminder Date']);
}
function setListValidation_(sheet, field, values, headers) {
const headerList = headers || CANDIDATE_HEADERS;
const column = headerList.indexOf(field) + 1;
if (!column) return;
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(values, true)
.setAllowInvalid(false)
.build();
sheet.getRange(2, column, sheet.getMaxRows() - 1, 1)
.setDataValidation(rule);
}
function formatDateColumns_(sheet, headers, fields) {
fields.forEach(field => {
const column = headers.indexOf(field) + 1;
if (column) {
sheet.getRange(2, column, sheet.getMaxRows() - 1, 1)
.setNumberFormat('yyyy-mm-dd hh:mm');
}
});
}
function buildDashboard_(sheet) {
sheet.clear();
sheet.getRange('A1:B1').setValues([['Metric', 'Value']])
.setFontWeight('bold');
const metrics = [
['Total candidates', '=COUNTA(Candidates!A2:A)'],
['Recruiter review', '=COUNTIF(Candidates!W2:W,"Recruiter Review")'],
['Manager review', '=COUNTIF(Candidates!W2:W,"Hiring Manager Review")'],
['Interviews scheduled', '=COUNTIF(Candidates!W2:W,"Interview Scheduled")'],
['Offer approval', '=COUNTIF(Candidates!W2:W,"Offer Approval")'],
['Needs information', '=COUNTIF(Candidates!W2:W,"Needs Information")'],
['Automation failures', '=COUNTIF(Candidates!AS2:AS,"Failed")'],
['Manual review', '=COUNTIF(Candidates!AS2:AS,"Manual Review")'],
['Hired', '=COUNTIF(Candidates!W2:W,"Hired")']
];
sheet.getRange(2, 1, metrics.length, 2).setValues(metrics);
sheet.autoResizeColumns(1, 2);
}
function addBusinessDays_(date, days) {
const result = new Date(date);
let remaining = Math.max(0, Number(days) || 0);
while (remaining > 0) {
result.setDate(result.getDate() + 1);
const day = result.getDay();
if (day !== 0 && day !== 6) remaining -= 1;
}
return result;
}
function getOrCreateFolder_(parent, name) {
const folders = parent.getFoldersByName(name);
return folders.hasNext() ? folders.next() : parent.createFolder(name);
}
function namedValue_(namedValues, key) {
const value = namedValues[key];
return Array.isArray(value) ? value.join(', ').trim() :
String(value || '').trim();
}
function extractDriveIds_(value) {
return String(value || '').match(/[-\w]{25,}/g) || [];
}
function lineValue_(body, label) {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = String(body || '').match(
new RegExp('^' + escaped + '\\s*:\\s*(.+)$', 'im')
);
return match ? match[1].trim() : '';
}
function extractEmail_(value) {
const match = String(value || '').match(
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i
);
return match ? match[0].toLowerCase() : '';
}
function extractDisplayName_(value) {
return String(value || '')
.replace(/<[^>]+>/g, '')
.replace(/["']/g, '')
.trim();
}
function normalizeEmail_(value) {
return String(value || '').trim().toLowerCase();
}
function sanitizeFileName_(value) {
return String(value || 'Unknown')
.replace(/[\\/:*?"<>|#%{}~]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
function splitEmails_(value) {
return String(value || '')
.split(/[;,]/)
.map(email => email.trim().toLowerCase())
.filter(Boolean);
}
function joinEmails_(values) {
const flattened = [];
values.forEach(value => {
splitEmails_(value).forEach(email => flattened.push(email));
});
return Array.from(new Set(flattened)).join(',');
}
function objectToRow_(object, headers) {
return headers.map(header =>
Object.prototype.hasOwnProperty.call(object, header) ?
object[header] : ''
);
}
function rowToObject_(values, headers) {
return headers.reduce((object, header, index) => {
object[header] = values[index];
return object;
}, {});
}
function headerMap_(headers) {
return headers.reduce((map, header, index) => {
map[header] = index;
return map;
}, {});
}
function sameDay_(first, second) {
if (!(first instanceof Date) || !(second instanceof Date)) return false;
return startOfDay_(first).getTime() === startOfDay_(second).getTime();
}
function startOfDay_(date) {
const value = new Date(date);
value.setHours(0, 0, 0, 0);
return value;
}
function dateKey_(date) {
const value = date instanceof Date ? date : new Date();
return Utilities.formatDate(value, ATS.timeZone, 'yyyyMMddHHmmss');
}
function isPlaceholder_(value) {
return !value || String(value).startsWith('YOUR_');
}
function validateConfiguration_(requireAll) {
if (isPlaceholder_(ATS.spreadsheetId)) {
throw new Error('Replace YOUR_SPREADSHEET_ID.');
}
if (requireAll) {
[
['rootFolderId', ATS.rootFolderId],
['calendarId', ATS.calendarId],
['fallbackRecruiterEmail', ATS.fallbackRecruiterEmail],
['hrManagerEmail', ATS.hrManagerEmail],
['timeZone', ATS.timeZone]
].forEach(item => {
if (isPlaceholder_(item[1])) {
throw new Error('Replace configuration value: ' + item[0]);
}
});
}
}
function getBook_() {
return SpreadsheetApp.openById(ATS.spreadsheetId);
}
After replacing the values, save the project and set the Apps Script project time zone to the same IANA time zone used in ATS.timeZone. Examples of IANA values include region-based names maintained by the time-zone database. Do not use an ambiguous abbreviation such as EST.
Run setupAts once. Review the authorization screen, approve the required scopes, and inspect the execution result. Populate the Jobs sheet, then run installTriggers.
Inspect runs in the Apps Script Executions view. Console messages appear with each execution. Business failures also appear in the Errors sheet, while significant successful actions appear in Audit Log.
Duplicate execution is prevented through source IDs, email-plus-job matching, a script lock, Calendar interview IDs, and audit action keys. Failed record actions are retried by the daily process up to three times. After that, the record remains in Manual Review until a system owner corrects the data and reprocesses the intended status.
Common setup errors include incorrect folder IDs, a trigger owner without calendar access, mismatched form question titles, invalid email addresses in Jobs, and a project time zone that differs from the spreadsheet time zone.
Failure Handling and Operational Reliability
| Failure | Automated response | Manual recovery | Owner |
|---|---|---|---|
| Missing candidate email | No Candidate row is created; Errors entry is written. | Correct the source submission and reapply the Gmail label or submit a corrected test event. | Recruiting coordinator |
| Missing resume | Candidate enters Needs Information with Missing Resume exception. | Obtain and upload the resume, then move to Recruiter Review. | Recruiting coordinator |
| Inactive or invalid job | Candidate enters Needs Information with Invalid Job exception. | Correct the job code or obtain HR approval for the requisition. | HR manager |
| Duplicate form event | Source Record ID check returns the existing record. | No action unless the source record was intentionally changed. | Automation |
| Duplicate candidate and job | Second record is suppressed and an audit entry is added. | Review whether the new submission contains updated documents. | Recruiter |
| Failed file copy | Candidate remains visible with an exception and error note. | Copy or upload the file manually and update the file fields. | Recruiting coordinator |
| Calendar access failure | Status action becomes Failed; no event ID is stored. | Restore calendar access and rerun the status action. | HR system owner |
| Duplicate calendar trigger | Interview ID lookup returns the existing event record. | Verify the Calendar event ID and remove only confirmed duplicates. | Recruiting coordinator |
| Unavailable approver | Pending approval remains visible and reminders continue. | Assign an authorized delegate and document the change. | HR manager |
| Offer without approval | Offer email is blocked and record becomes Failed. | Complete approval, verify the document, authorize communication, and rerun. | HR manager |
| Rejection without communication approval | Candidate email is blocked. | Review the decision and template, then approve communication. | Recruiter |
| Gmail quota or temporary service failure | Error is logged and the action remains retryable. | Wait for service availability or quota renewal, then rerun. | HR system owner |
| Expired or revoked authorization | Trigger fails and appears in Apps Script executions. | Reauthorize with the automation account and reinstall triggers if needed. | Workspace administrator |
| Invalid notification address | Validation blocks the send and records the field error. | Correct the Jobs or Candidates email field. | Recruiting coordinator |
The Errors sheet serves as a lightweight dead-letter queue. It records work that could not complete normally. Records are not deleted from the queue when resolved. Instead, the owner changes Status to Resolved and records a Resolved Date, preserving the operational history.
Daily reconciliation compares:
- Recent form responses to Candidates Source Record IDs.
- Processed Gmail threads to Gmail-based Source Record IDs.
- Candidate Calendar Event IDs to Interviews records.
- Failed Candidate rows to open Errors entries.
- Offer Sent records to Approved offer status and communication authorization.
Apps Script and Google services enforce quotas and execution limits that vary by account and service. The process should use bounded batches, as the email function does, and avoid assuming unlimited messages, file operations, or execution time.
A Complete Example
A fictional applicant named Jordan Lee applies for the Senior Implementation Consultant role using the website form.
- Jordan selects job code
CON-2026-01, enters an email address, provides the required privacy acknowledgement, and uploads a sample PDF resume. - The form writes a response row. The installed trigger receives the named values and creates source ID
FORM:184726351:27. - The script validates the email and checks that the source ID has not already been processed.
- It searches active Candidate rows for Jordan’s normalized email plus
CON-2026-01. No duplicate exists. - The Jobs lookup returns the job title, recruiting coordinator, hiring manager, offer approver, and a three-business-day review target.
- The sequence generator returns
AP-2026-0042. - Drive folders are created under
2026/AP-2026-0042 - Jordan Lee. The resume copy is renamed and its Drive ID is saved. - The Candidates row enters
Recruiter Review. The recruiter becomes the owner, the due date is calculated, and Automation Status becomes Completed. - Gmail sends one acknowledgement. Audit Log records
CANDIDATE_CREATEDandEMAIL_ACK. - After reviewing the resume, the recruiter changes Status to
Hiring Manager Review. The hiring manager receives a review request containing the Candidate ID and controlled folder link. - The hiring manager approves an interview. The recruiter changes Status to
Interview Requested, causing Gmail to send the scheduling instructions. - After agreeing on a time, the recruiter enters Panel Interview, start and end times, panel emails, Video, and the approved meeting link. The recruiter changes Status to
Interview Scheduled. - Apps Script creates the Calendar event and receives an event ID. It appends an Interviews row such as
AP-2026-0042-INT-202608191400. - Approximately 24 hours before the event, participants receive a reminder. After the event ends, the Candidate status changes to
Interview Complete, and the panel receives a feedback request. - The hiring manager recommends an offer. HR prepares an offer document, enters its link, and changes Status to
Offer Approval. - The approver changes Offer Approval Status to Approved. HR reviews the final document and authorizes candidate communication.
- When HR changes Status to
Offer Sent, the script verifies approval, communication authorization, and the document link before sending the candidate email. - After Jordan accepts and HR verifies the acceptance, an authorized HR user changes Status to
Hired. The workflow records the final status and sends the internal hiring notice.
If Calendar creation failed at step 12, the record would show Automation Status Failed, retain the intended interview data, and create an Errors row. No Calendar Event ID would be stored. After access was corrected, the system owner could rerun the status action without recreating the candidate or resending the original acknowledgement.
Implementation Cost
All amounts below are representative planning assumptions, not verified client results or Intelligex pricing. Existing Google Workspace subscription costs are excluded because the fictional firm already licenses the tools. Organizations without the required features should obtain current licensing information from Google.
| Category | Assumption | Representative cost |
|---|---|---|
| Process design | 6 internal hours at $50 per hour | $300 one time |
| Spreadsheet, form, and folder configuration | 8 internal hours at $50 per hour | $400 one time |
| Apps Script implementation and review | 24 specialist hours at $150 per hour | $3,600 one time |
| Testing and remediation | 10 mixed hours | $900 one time |
| Training | 4 internal hours at $50 per hour | $200 one time |
| Documentation and launch | 6 mixed hours | $800 one time |
| Total assumed implementation | Combined representative budget | $6,200 one time |
| Incremental core software | Existing Google Workspace tools | $0 incremental monthly in this scenario |
| Maintenance labour | 2 hours per month at $45 per hour | $90 internal monthly cost |
| Optional AI API | Model, file size, and volume dependent | $5 monthly planning allowance |
| Optional expanded implementation | Migration, advanced dashboards, approval forms, or additional integrations | Scope separately |
A zero incremental software line does not mean the system is free. It depends on an existing Workspace subscription, implementation labour, maintenance, account administration, testing, and quota monitoring.
Estimated Time and Cost Savings
The calculation uses these representative assumptions:
- 60 applications per month.
- 32 minutes of current administrative handling per application.
- 10 minutes of routine handling after core automation.
- 12 percent of applications require exception handling.
- 12 minutes of manual work per exception.
- 2 hours of monthly maintenance.
- $45 loaded hourly labour cost.
- $0 incremental monthly core software cost because Workspace is already licensed.
- $6,200 one-time implementation cost.
Current monthly labour hours: Monthly volume × current minutes per record ÷ 60
New monthly labour hours: Monthly volume × new minutes per record ÷ 60, plus exception handling and maintenance
Monthly hours recovered: Current monthly labour hours minus new monthly labour hours
Estimated monthly labour value: Monthly hours recovered × loaded hourly labour cost
Net estimated monthly value: Monthly labour value minus recurring tool costs
Estimated payback period: One-time implementation cost ÷ net estimated monthly value
| Measure | Calculation | Result |
|---|---|---|
| Current labour | 60 × 32 ÷ 60 | 32.00 hours |
| New routine labour | 60 × 10 ÷ 60 | 10.00 hours |
| Exception labour | 60 × 12% × 12 ÷ 60 | 1.44 hours |
| Maintenance | Monthly allowance | 2.00 hours |
| Total new labour | 10.00 + 1.44 + 2.00 | 13.44 hours |
| Hours recovered | 32.00 – 13.44 | 18.56 hours |
| Monthly labour value | 18.56 × $45 | $835.20 |
| Net monthly value | $835.20 – $0 incremental core software | $835.20 |
| Estimated payback | $6,200 ÷ $835.20 | Approximately 7.4 months |
Recovered time does not automatically reduce payroll. It may provide additional recruiting capacity, faster candidate turnaround, reduced overtime, fewer administrative tasks, better coverage during employee absence, or the ability to handle more open roles without adding the same amount of coordination work.
Non-financial benefits include clearer ownership, fewer follow-up emails, consistent folder organization, better audit evidence, more reliable pipeline reporting, fewer incomplete records, and a more predictable applicant experience.
Readers should replace the application volume, current handling time, automated handling time, exception rate, maintenance time, labour cost, software cost, and implementation cost with their own measured figures.
Adding AI to the Automation
AI is an optional enhancement added only after intake, status, document, communication, retry, and audit functions operate reliably.
Useful AI applications in this workflow include:
- Extracting explicit contact, employment, education, certification, and skill details from resumes.
- Creating a short factual resume summary for recruiter review.
- Identifying fields that appear missing or unclear.
- Grouping explicitly stated skills for reporting.
- Supporting semantic search across recruiter-approved summaries.
AI is not required for job-code validation, exact duplicate checks, status transitions, due-date calculations, approval thresholds, permissions, or communication prerequisites. Those tasks are better handled with deterministic rules.
The core automation creates the operational savings from intake, file management, assignments, notifications, reminders, and reporting. AI provides a narrower benefit by reducing the time required to read and transcribe unstructured resume information.
The Recommended AI Enhancement
The recommended enhancement extracts resume facts into structured JSON for recruiter review. It does not score applicants, recommend advancement, compare candidates, or make a hiring decision.
- Trigger: A recruiter selects a Candidate row and runs
Extract resume for active row. - AI input: One approved resume file from the candidate’s controlled Drive folder.
- System instruction: Extract explicit facts only, ignore protected characteristics, and do not make a recommendation.
- Expected output: Strict JSON containing contact details, recent role, years-of-experience estimate, skills, education, certifications, summary, unclear items, and confidence.
- Validation: Apps Script verifies required keys and confidence range before updating the row.
- Record update: AI fields are populated and AI Status becomes Review Required.
- Human review: A recruiter compares every field with the resume and corrects or accepts it.
- Low confidence: Any overall confidence below 0.75 remains highlighted for manual extraction.
- Prohibited data: The prompt instructs the model not to extract or infer age, race, ethnicity, religion, disability, medical information, family status, gender identity, sexual orientation, or other protected characteristics.
- Logging: The Audit Log records the model name and confidence, not the API key or full prompt response.
- Failure behavior: AI Status becomes Failed, while the core candidate workflow continues normally.
The reusable system instruction is:
You extract factual resume information for recruiter review.
Use only information explicitly present in the supplied resume. Do not infer
protected characteristics, personality, cultural fit, job suitability, or a
hiring recommendation. Do not rank or score the candidate. Ignore photographs
and any references to age, race, ethnicity, religion, disability, medical
information, family status, gender identity, sexual orientation, or other
protected characteristics.
If a value is absent or uncertain, return null, an empty array, or explain the
uncertainty in missing_or_unclear. Keep the resume summary factual and under
90 words. Return only data matching the required JSON schema.
The user prompt is:
Extract explicit resume facts for candidate record YOUR_CANDIDATE_ID.
The output will be checked by a recruiter against the original resume.
Do not decide whether the person should advance and do not compare the person
with a job description or another applicant.
The expected JSON structure is:
{
"candidate_name": "Jordan Lee",
"email": "[email protected]",
"phone": null,
"location": "Region stated on resume",
"current_title": "Implementation Consultant",
"recent_employer": "Employer stated on resume",
"years_experience_estimate": 7,
"skills": [
"Process mapping",
"Project delivery",
"SQL"
],
"education_summary": "Degree and institution explicitly stated",
"certifications": [
"Certification explicitly stated"
],
"resume_summary": "Factual summary under 90 words.",
"missing_or_unclear": [
"Phone number was not found"
],
"overall_confidence": 0.88
}
The following add-on uses the OpenAI Responses API and Files API. Confirm that the selected model supports file input and structured output by consulting the current official Responses API documentation. Model availability and data controls can change.
Paste this code below the core script. In Apps Script Project Settings, add script properties named OPENAI_API_KEY and OPENAI_MODEL. Do not store the key in the spreadsheet.
const AI_SYSTEM_PROMPT = `You extract factual resume information for recruiter
review.
Use only information explicitly present in the supplied resume. Do not infer
protected characteristics, personality, cultural fit, job suitability, or a
hiring recommendation. Do not rank or score the candidate. Ignore photographs
and any references to age, race, ethnicity, religion, disability, medical
information, family status, gender identity, sexual orientation, or other
protected characteristics.
If a value is absent or uncertain, return null, an empty array, or explain the
uncertainty in missing_or_unclear. Keep the resume summary factual and under
90 words. Return only data matching the required JSON schema.`;
function runAiForActiveRow() {
const sheet = SpreadsheetApp.getActiveSheet();
const row = sheet.getActiveRange().getRow();
if (sheet.getName() !== 'Candidates' || row < 2) {
throw new Error('Select one candidate row on the Candidates sheet.');
}
const properties = PropertiesService.getScriptProperties();
const apiKey = properties.getProperty('OPENAI_API_KEY');
const model = properties.getProperty('OPENAI_MODEL');
if (!apiKey || !model) {
throw new Error(
'Set OPENAI_API_KEY and OPENAI_MODEL in Script Properties.'
);
}
const record = getCandidateRecord_(row);
const fileIds = String(record['Resume File IDs'] || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
if (!fileIds.length) {
throw new Error('No controlled resume file ID is available.');
}
updateCandidateFields_(row, {
'AI Status': 'Processing',
'Last Updated': new Date()
});
let uploadedFileId = '';
try {
const driveFile = DriveApp.getFileById(fileIds[0]);
let blob = driveFile.getBlob();
if (driveFile.getMimeType() === MimeType.GOOGLE_DOCS) {
blob = driveFile.getAs(MimeType.PDF)
.setName(driveFile.getName() + '.pdf');
}
const maximumBytes = 10 * 1024 * 1024;
if (blob.getBytes().length > maximumBytes) {
throw new Error('Resume exceeds the configured 10 MB AI limit.');
}
const upload = fetchJsonWithRetry_(
'https://api.openai.com/v1/files',
{
method: 'post',
headers: {
Authorization: 'Bearer ' + apiKey
},
payload: {
purpose: 'user_data',
file: blob
},
muteHttpExceptions: true
},
[200]
);
uploadedFileId = upload.id;
if (!uploadedFileId) {
throw new Error('File API did not return a file ID.');
}
const schema = {
type: 'object',
additionalProperties: false,
properties: {
candidate_name: { type: ['string', 'null'] },
email: { type: ['string', 'null'] },
phone: { type: ['string', 'null'] },
location: { type: ['string', 'null'] },
current_title: { type: ['string', 'null'] },
recent_employer: { type: ['string', 'null'] },
years_experience_estimate: { type: ['number', 'null'] },
skills: {
type: 'array',
items: { type: 'string' }
},
education_summary: { type: ['string', 'null'] },
certifications: {
type: 'array',
items: { type: 'string' }
},
resume_summary: { type: 'string' },
missing_or_unclear: {
type: 'array',
items: { type: 'string' }
},
overall_confidence: {
type: 'number',
minimum: 0,
maximum: 1
}
},
required: [
'candidate_name', 'email', 'phone', 'location', 'current_title',
'recent_employer', 'years_experience_estimate', 'skills',
'education_summary', 'certifications', 'resume_summary',
'missing_or_unclear', 'overall_confidence'
]
};
const requestBody = {
model: model,
store: false,
input: [
{
role: 'system',
content: [
{
type: 'input_text',
text: AI_SYSTEM_PROMPT
}
]
},
{
role: 'user',
content: [
{
type: 'input_file',
file_id: uploadedFileId
},
{
type: 'input_text',
text:
'Extract explicit resume facts for candidate record ' +
record['Candidate ID'] +
'. The output will be checked by a recruiter against the ' +
'original resume. Do not decide whether the person should ' +
'advance and do not compare the person with a job ' +
'description or another applicant.'
}
]
}
],
text: {
format: {
type: 'json_schema',
name: 'resume_extraction',
strict: true,
schema: schema
}
}
};
const response = fetchJsonWithRetry_(
'https://api.openai.com/v1/responses',
{
method: 'post',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
payload: JSON.stringify(requestBody),
muteHttpExceptions: true
},
[200]
);
const outputText = extractResponseOutputText_(response);
const extracted = JSON.parse(outputText);
validateAiExtraction_(extracted);
updateCandidateFields_(row, {
'AI Status': 'Review Required',
'AI Confidence': extracted.overall_confidence,
'AI Suggested Current Title': extracted.current_title || '',
'AI Suggested Years Experience':
extracted.years_experience_estimate === null ?
'' : extracted.years_experience_estimate,
'AI Suggested Skills': extracted.skills.join(', '),
'AI Summary': extracted.resume_summary,
'AI Extracted JSON': JSON.stringify(extracted),
'Last Updated': new Date()
});
audit_(record['Candidate ID'], 'AI_EXTRACTION_COMPLETED',
'Model ' + model + ' returned confidence ' +
extracted.overall_confidence + '. Recruiter review required.');
console.log(JSON.stringify({
candidateId: record['Candidate ID'],
aiStatus: 'Review Required',
confidence: extracted.overall_confidence
}));
} catch (error) {
updateCandidateFields_(row, {
'AI Status': 'Failed',
'Error Message': 'AI extraction: ' +
String(error.message || error).slice(0, 450),
'Last Updated': new Date()
});
recordError_(
record['Candidate ID'],
record['Source Record ID'],
'runAiForActiveRow',
error,
0
);
console.error(error.stack || error);
throw error;
} finally {
if (uploadedFileId) {
try {
UrlFetchApp.fetch(
'https://api.openai.com/v1/files/' +
encodeURIComponent(uploadedFileId),
{
method: 'delete',
headers: {
Authorization: 'Bearer ' + apiKey
},
muteHttpExceptions: true
}
);
} catch (deleteError) {
console.error(
'Temporary AI file deletion failed: ' + deleteError.message
);
}
}
}
}
function fetchJsonWithRetry_(url, options, acceptedCodes) {
const maximumAttempts = 3;
let lastError;
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
try {
const response = UrlFetchApp.fetch(url, options);
const status = response.getResponseCode();
const text = response.getContentText();
if (acceptedCodes.includes(status)) {
return JSON.parse(text);
}
const retryable = status === 429 || status >= 500;
lastError = new Error(
'API returned HTTP ' + status + ': ' + text.slice(0, 500)
);
if (!retryable || attempt === maximumAttempts) {
throw lastError;
}
} catch (error) {
lastError = error;
if (attempt === maximumAttempts) throw error;
}
Utilities.sleep(Math.pow(2, attempt - 1) * 1000);
}
throw lastError || new Error('API request failed.');
}
function extractResponseOutputText_(response) {
const output = response.output || [];
for (let index = 0; index < output.length; index += 1) {
const content = output[index].content || [];
for (let contentIndex = 0;
contentIndex < content.length;
contentIndex += 1) {
if (content[contentIndex].type === 'output_text' &&
content[contentIndex].text) {
return content[contentIndex].text;
}
}
}
throw new Error('AI response did not contain output_text.');
}
function validateAiExtraction_(value) {
const required = [
'candidate_name', 'email', 'phone', 'location', 'current_title',
'recent_employer', 'years_experience_estimate', 'skills',
'education_summary', 'certifications', 'resume_summary',
'missing_or_unclear', 'overall_confidence'
];
required.forEach(key => {
if (!Object.prototype.hasOwnProperty.call(value, key)) {
throw new Error('AI output is missing required key: ' + key);
}
});
if (!Array.isArray(value.skills) ||
!Array.isArray(value.certifications) ||
!Array.isArray(value.missing_or_unclear)) {
throw new Error('AI output contains an invalid array field.');
}
if (typeof value.overall_confidence !== 'number' ||
value.overall_confidence < 0 ||
value.overall_confidence > 1) {
throw new Error('AI confidence must be a number from 0 to 1.');
}
}
Run the optional function only with synthetic test resumes first. Expected output is a populated set of AI columns and AI Status Review Required. An HTTP 401 generally indicates an invalid API key. HTTP 429 indicates a rate or usage constraint. HTTP 400 commonly indicates an unsupported file, model, or request feature. The function retries rate-limit and server errors, but not persistent validation errors.
Benefits of the AI Enhancement
AI-specific benefits are narrower than the benefits of the core automation:
- Less time transcribing employment, skills, education, and certification details.
- More consistent field structure across differently formatted resumes.
- Faster identification of missing or unclear resume information.
- Short factual summaries for recruiter review.
- Structured skill data that can support recruiter-approved reporting.
AI does not create the candidate record, manage status, assign ownership, create folders, schedule interviews, approve offers, or send rejection decisions. Those benefits come from normal workflow automation.
What Remains Rule-Based or Human-Controlled
| Decision | Control | Reason |
|---|---|---|
| Required-field validation | Rule-based | Exact requirements should not depend on probabilistic output. |
| Duplicate suppression | Rule-based with human review for ambiguous cases | Email and job matching can be explained and audited. |
| Candidate priority | Human-controlled | Priority can materially affect treatment and should reflect an approved business reason. |
| Advancement to interview | Human-controlled | Requires role-specific assessment and accountability. |
| Candidate rejection | Human-controlled | It is a consequential employment decision. |
| Interview evaluation | Human-controlled | AI resume extraction does not assess interview evidence. |
| Offer terms and approval | Human-controlled | Compensation, legal, and budget decisions require authorized review. |
| Final hiring decision | Human-controlled | The organization must retain responsibility and decision evidence. |
| AI field acceptance | Human-controlled | Resume extraction can omit, misread, or misinterpret information. |
Estimating the Additional Value of AI
The following assumptions are representative and should be validated through a controlled sample:
- 55 of 60 monthly applications contain a machine-readable resume.
- Manual extraction and summarization takes 4 minutes per eligible resume.
- AI output review takes 1.5 minutes per resume.
- 20 percent of outputs require 2 additional minutes of correction.
- 5 percent fail and require the original 4-minute manual process.
- Monthly AI usage allowance is $5.
| Process | Representative handling | Monthly labour |
|---|---|---|
| Original process | 32 minutes per application | 32.00 hours |
| Core automation | 10 minutes per application plus exceptions and maintenance | 13.44 hours |
| AI resume review component | 82.5 review minutes + 22 correction minutes + 11 failure minutes | 1.93 hours for resume extraction work |
| Manual resume extraction component | 55 × 4 minutes | 3.67 hours |
| Additional capacity from AI | 3.67 – 1.93 | Approximately 1.74 hours monthly |
At $45 per hour, 1.74 hours represents approximately $78.30 in monthly labour capacity. After a $5 API allowance, the estimated additional monthly value is approximately $73.30.
This estimate does not assume perfect extraction. It explicitly includes review, correction, and failure time. The business should sample output accuracy by field type before relying on the capacity estimate.
Testing Checklist
Use fictional sample data and non-sensitive documents before processing real applicant information.
| Test | Expected result |
|---|---|
| Normal form submission | Candidate, folders, resume copy, acknowledgement, ownership, and audit entries are created. |
| Normal labeled email | Email is parsed, Candidate is created, and Gmail label changes to Processed. |
| Missing required field | Invalid submission is rejected or routed to Needs Information as designed. |
| Invalid email | No unusable Candidate record is created; Errors entry is written. |
| Invalid job code | Candidate enters Needs Information with Invalid Job exception. |
| Duplicate submission | Existing active candidate is returned and duplicate creation is suppressed. |
| Duplicate trigger event | Source Record ID prevents a second record. |
| Failed authentication | Execution fails visibly without exposing credentials. |
| Expired authorization | Trigger failure appears in Apps Script; reauthorization restores operation. |
| Failed Drive copy | Candidate remains visible with a missing-file exception. |
| Failed Calendar creation | No event ID is saved; record enters Failed or Manual Review. |
| Duplicate interview event | Interview ID prevents a second event for the same round and time. |
| Unavailable approver | Approval remains pending until an authorized delegate is assigned. |
| Offer rejection | HR receives an internal action notice; no candidate offer email is sent. |
| Candidate rejection without approval | Rejection email is blocked. |
| Candidate rejection with approval | One approved rejection email is sent and audited. |
| Reassignment | New owner receives future reminders; audit note documents the change. |
| Overdue item | Owner reminder is sent after the due date. |
| Escalation | HR manager is included after the configured reminder threshold. |
| Failed notification | Error and retry count are recorded. |
| Unauthorized user | Protected fields or Drive permissions prevent the action. |
| Malformed AI output | Schema or local validation rejects it and AI Status becomes Failed. |
| Inaccurate AI output | Recruiter corrects it before acceptance; no hiring action occurs automatically. |
| AI service failure | Core workflow continues and manual resume review remains available. |
| Successful completion | Hired, Rejected, or Withdrawn appears correctly in reports. |
| Correct reporting | Dashboard totals reconcile to source rows and filters. |
| Correct audit record | Important actions contain timestamp, Candidate ID, action, actor, and concise details. |
| Correct retry behavior | Retryable action runs no more than three times and does not duplicate consequential emails. |
Ongoing Maintenance
| Frequency | Task | Primary owner |
|---|---|---|
| Daily | Review failed runs, Errors, Manual Review records, and unprocessed Gmail threads. | Recruiting coordinator |
| Weekly | Reconcile form responses, candidate records, interviews, and processed email labels. | HR system owner |
| Monthly | Review reminder volumes, exception rates, Apps Script quotas, and maintenance time. | HR manager |
| Monthly | Sample candidate communications and optional AI output for accuracy. | HR manager |
| Quarterly | Review spreadsheet, folder, calendar, mailbox, and script permissions. | Workspace administrator |
| Quarterly | Run duplicate, failed-authentication, file, calendar, reminder, approval, and rollback tests. | HR system owner |
| Quarterly | Review job, form, email-template, and status definitions. | People Operations |
| As required | Rotate API credentials and reauthorize revoked connections. | Workspace administrator |
| As required | Remove former employees and transfer trigger ownership. | Workspace administrator |
| Annually | Review retention, privacy, backup, AI-provider, and regulatory requirements. | HR and legal owners |
The HR system owner maintains the process documentation, field dictionary, status definitions, email templates, test cases, and recovery instructions. A backup owner should know how to disable triggers, inspect executions, process failed records, and restore controlled manual operation.
When to Move to Dedicated Software
The Google Workspace implementation does not need to be replaced merely because it uses Sheets and Apps Script. It should be reassessed when the operating requirements exceed the controls it can provide efficiently.
Upgrade indicators include:
- Application volume creates frequent Apps Script quota or execution problems.
- The firm manages many simultaneous jobs, locations, business units, or recruiting teams.
- Multiple interview rounds require reusable interview plans and complex panel workflows.
- Job-board posting and application synchronization become important.
- Candidates require a secure self-service portal.
- Formal compliance, consent, deletion, or audit reporting exceeds the spreadsheet model.
- Field-level permissions become too complex for Sheets protections.
- Recruiters require mobile or offline workflows.
- Hiring agencies or external recruiters need controlled portal access.
- Exception rates and script maintenance continue increasing.
- Leadership requires advanced workforce analytics or data-warehouse integration.
- The business needs vendor support commitments and formal service levels.
- Security risk grows because too many users require access to the master spreadsheet.
At that point, the business can evaluate dedicated applicant tracking systems, recruiting modules in an HR information system, or a controlled custom recruiting application. The structured fields, status definitions, IDs, document hierarchy, and audit data created here make eventual migration more manageable.
Implementation Checklist
- Confirm application volume, channels, roles, owners, and service-level expectations.
- Approve the selected Google Workspace tools and optional Calendar connection.
- Create the production and test accounts, mailbox labels, folders, calendars, forms, and spreadsheets.
- Assign least-privilege permissions and a backup system owner.
- Create the Candidates, Jobs, Interviews, Audit Log, Errors, and Dashboard structures.
- Populate active job codes, recruiters, hiring managers, approvers, and SLA values.
- Build and validate the applicant form and email template.
- Document the Google Forms file-upload sign-in limitation and alternate intake method.
- Replace every script configuration placeholder.
- Verify field mappings for form and email sources.
- Run the setup function and inspect generated validation and formulas.
- Install form, edit, email-processing, and daily reminder triggers.
- Test identifiers, source idempotency, and candidate duplicate handling.
- Test automatic folder creation, file copies, naming, links, and access.
- Test recruiter assignment, manager review, interview scheduling, and Calendar IDs.
- Test approvals, reminders, escalations, unavailable approvers, and reassignment.
- Test acknowledgement, scheduling, rejection, offer, and internal notifications.
- Protect approval, automation, source ID, and audit fields.
- Verify dashboard counts, overdue views, exceptions, and automation-failure reporting.
- Test authentication failures, retries, manual recovery, and rollback.
- Complete user acceptance testing with fictional sample information.
- Document implementation, software, labour, maintenance, and savings assumptions.
- Enable optional AI only after the core workflow is stable and approved.
- Require recruiter review of every AI extraction and monitor cost and accuracy.
- Assign daily, weekly, monthly, quarterly, and annual maintenance responsibilities.
- Define the transaction, security, compliance, portal, and maintenance thresholds that will trigger evaluation of dedicated software.
Department/Function: Human Resources & People Ops
Get a FREE
Proof of Concept
& Consultation
No Cost, No Commitment!


