Generate tailored cover letters from a profile
Customise cover letters per job from a Sheet of openings and a profile blurb.
Published Nov 30, 2025
A Northwind hire chasing freelance briefs faces the same chore every week: write a fresh cover letter for each opening. The letters are 90% the same — the same voice, the same closing — and 10% specific: the company name, the role, and one line that shows you actually read the brief. Retyping the shared part is wasted effort, and copy-pasting it invites the classic mistake of leaving last week’s company name in this week’s letter.
This script keeps the openings in a Sheet and the letter in a Doc template. For every row that has not been done yet, it copies the template, swaps the three placeholders for that opening’s details, and writes the new Doc’s URL back into the sheet. Run it whenever you have added openings — it only ever touches the new rows.
What you’ll need
- An
OpeningsGoogle Sheet with a header row and four columns:company,role,hook(the one tailored line for that job), andoutput(left blank — the script fills it in). - A cover letter Doc template containing the placeholders
{{company}},{{role}}, and{{hook}}wherever those details should appear.
The script
// The Doc template the script copies for each letter.
const TEMPLATE_ID = '1abcCoverTemplateId';
// The sheet of job openings to work through.
const OPENINGS_SHEET_ID = '1abcOpeningsId';
/**
* Generates one cover letter Doc per unprocessed row in the Openings
* sheet, then records each Doc's URL back in the "output" column.
*/
function generateCovers() {
const sheet = SpreadsheetApp.openById(OPENINGS_SHEET_ID).getSheets()[0];
// 1. Read the whole sheet, split off the header, and map column
// names to indexes so the code reads by name, not by number.
const values = sheet.getDataRange().getValues();
const [h, ...rows] = values;
const col = Object.fromEntries(h.map((k, i) => [k, i]));
// 2. Bail out if there are no data rows at all.
if (rows.length === 0) {
Logger.log('No openings to process — nothing to do.');
return;
}
let created = 0;
// 3. Walk every row. Skip any that already has an output URL, so
// re-running the script never duplicates a letter.
rows.forEach((r, i) => {
if (r[col.output]) return;
// 4. Copy the template into a clearly named Doc.
const copy = DriveApp.getFileById(TEMPLATE_ID).makeCopy(
`Cover — ${r[col.company]} — ${r[col.role]}`);
// 5. Open the copy and swap each placeholder for this row's value.
const doc = DocumentApp.openById(copy.getId());
const body = doc.getBody();
body.replaceText('{{company}}', r[col.company]);
body.replaceText('{{role}}', r[col.role]);
body.replaceText('{{hook}}', r[col.hook]);
doc.saveAndClose();
// 6. Record the URL in the in-memory copy of the sheet values.
values[i + 1][col.output] = copy.getUrl();
created++;
});
// 7. Write the whole grid back in one call so the output column sticks.
sheet.getDataRange().setValues(values);
Logger.log(`Generated ${created} cover letter(s).`);
}
How it works
generateCoversopens theOpeningssheet and reads every row into memory in onegetDataRangecall.- It splits the header off and builds a
collookup, so the rest of the code can sayr[col.company]instead of remembering that company is column 0. - If there are no data rows it logs and stops.
- For each row, it skips any that already has a value in
output— that is the guard that makes the script safe to run again and again. - For a fresh row, it copies the template Doc, names the copy after the company and role, and opens it.
replaceTextswaps{{company}},{{role}}, and{{hook}}for the row’s values. The copy is saved and its URL stored back into the in-memory grid.- After the loop, one
setValuescall writes the whole grid back, so theoutputcolumn now holds a link for every row processed.
Example run
Say the Openings sheet starts like this, with output empty:
| company | role | hook | output |
|---|---|---|---|
| Brightside Studio | Brand designer | your rebrand for Kestrel Coffee caught my eye | |
| Harbour & Co | Content strategist | I loved the tone of your sustainability report |
After a run, the sheet has links in output, and two new Docs exist:
- Cover — Brightside Studio — Brand designer, where the letter body now reads “…your rebrand for Kestrel Coffee caught my eye…”
- Cover — Harbour & Co — Content strategist, tailored with its own hook.
Add a third opening next week, run the script again, and only that third row gets a new Doc — the first two are skipped.
Run it
This is an on-demand job — run it after you add openings:
- In the Apps Script editor, select
generateCoversand click Run. - Approve the authorisation prompt the first time.
- Open the links that appear in the
outputcolumn.
To run it from the sheet without opening the editor, add a custom menu:
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Cover letters')
.addItem('Generate covers', 'generateCovers')
.addToUi();
}
Watch out for
- The placeholders in the template must match exactly, braces included.
{{ company }}with spaces will not be replaced and you will mail a letter with a literal placeholder in it. replaceTextswaps every occurrence. If{{company}}appears in both the address block and the body, both are filled — usually what you want, but worth knowing.- New Docs land in your My Drive root. To file them, pass a folder to
makeCopyas a second argument, or move the file withDriveAppafterwards. - The script trusts the sheet. A blank
hookcell leaves an empty line where the tailored sentence should be — check rows are complete before running. - Deleting a row’s
outputvalue and re-running will generate a second copy of that letter. Clear output cells only when you genuinely want a rebuild.
Related
Generate personalized study guides from notes
Reformat raw notes into structured study guides — for Northwind's internal training programme.
Updated Feb 8, 2026
Build a contract-clause assembly system
Construct Northwind agreements from a library of approved clauses — drag-drop in code.
Updated Feb 1, 2026
Translate and resolve Doc comments
Localise reviewer feedback on a shared Doc so multilingual teams can collaborate.
Updated Jan 25, 2026
Auto-archive finalized Docs to dated folders
File completed Northwind Docs by month so the active folder stays focused on in-flight work.
Updated Jan 18, 2026
Build a fillable intake form inside a Doc
Create structured intake forms with placeholder fields readers can fill — for client briefs.
Updated Jan 11, 2026