appscript.dev
Automation Intermediate Sheets

Expand role bullets into full job descriptions

Generate Northwind job postings from a Sheet of role bullets — outputs polished copy per role.

Published Nov 11, 2025

When Northwind opens a new role, the hiring manager jots a few bullet points — the skills, the day-to-day, the must-haves — and then someone has to turn that shorthand into a full job posting. It is a slow, repetitive write every time, and the postings drift in tone because a different person writes each one.

This script does the expansion in bulk. You keep a sheet with one row per role: a title, a column of rough bullets, and an empty column for the posting. The script reads each unfinished row, asks Claude to expand the bullets into a structured posting, and writes the result back. Every posting follows the same sections, so the whole careers page reads as one voice.

What you’ll need

  • A Google Sheet with one row per role. The first tab needs a header row with these column names exactly: title, bullets, posting. Put the rough bullets in the bullets column and leave posting empty.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.

The script

// The spreadsheet that holds one row per open role.
const ROLES_SHEET_ID = '1abcRolesId';

// The fixed sections every generated posting should contain.
const POSTING_SECTIONS =
  'About us (1 para), The role, What you bring, What we offer';

/**
 * Reads every role row that has bullets but no posting yet, asks Claude
 * to expand the bullets into a full job posting, and writes it back.
 */
function expandJobs() {
  const sheet = SpreadsheetApp.openById(ROLES_SHEET_ID).getSheets()[0];

  // 1. Read the whole sheet and split the header from the data rows.
  const [header, ...rows] = sheet.getDataRange().getValues();

  if (!rows.length) {
    Logger.log('No role rows to expand — nothing to do.');
    return;
  }

  // 2. Map column names to indexes so the code does not depend on order.
  const col = Object.fromEntries(header.map((name, i) => [name, i]));

  // 3. Walk each row; skip ones already done or with no bullets.
  rows.forEach((row, i) => {
    if (row[col.posting] || !row[col.bullets]) return;

    // 4. Build a prompt that pins the posting to a fixed set of sections.
    const prompt =
      'Expand these bullets into a full Northwind Studios job posting. ' +
      'Sections: ' + POSTING_SECTIONS + '.\n\n' +
      'Role: ' + row[col.title] + '\n' +
      'Bullets:\n' + row[col.bullets];

    // 5. Sonnet writes the posting; write it back to the posting column.
    const posting = callClaude(prompt, 'claude-sonnet-4-6', 1500);
    sheet.getRange(i + 2, col.posting + 1).setValue(posting);
  });
  Logger.log('Finished expanding role bullets.');
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
function callClaude(prompt, model = 'claude-haiku-4-5-20251001', maxTokens = 400) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('ANTHROPIC_API_KEY');
  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify({
      model,
      max_tokens: maxTokens,
      messages: [{ role: 'user', content: prompt }],
    }),
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. expandJobs opens the roles spreadsheet and reads the whole sheet in one call, splitting the header row off from the data rows.
  2. If there are no data rows, it logs a message and stops — no wasted API calls.
  3. It builds a col lookup that maps each header name to its column index, so the script keeps working even if the columns are reordered.
  4. It walks every row and skips two cases: rows that already have a posting (so reruns are cheap) and rows with no bullets to expand.
  5. For each remaining row it builds a prompt that names the role and lists the bullets, and pins the output to the fixed POSTING_SECTIONS.
  6. It calls Claude Sonnet — worth the cost for polished long-form copy — and writes the posting back into the posting column for that row.

Example run

Say one row of the sheet looks like this before a run:

titlebulletsposting
Junior Motion DesignerAfter Effects; 1-2 yrs experience; works to brief; portfolio required(empty)

After a run, the posting cell holds a full, structured advert:

About us — Northwind Studios is a small creative team building brand and motion work for ambitious clients…

The role — As our Junior Motion Designer you will take briefs from concept to finished animation in After Effects…

What you bring — One to two years of motion design experience, fluency in After Effects, and a portfolio that shows range…

What we offer — A close-knit team, real ownership of projects…

Every role in the sheet comes out with the same four sections, so the careers page reads consistently no matter who wrote the original bullets.

Run it

This is an on-demand job — run it whenever you have added new roles to the sheet:

  1. In the Apps Script editor, select expandJobs and click Run.
  2. Approve the authorisation prompt the first time.
  3. Open the sheet and read the filled-in posting column.

To let non-editors trigger it, add a custom menu so it appears in the spreadsheet itself:

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Hiring tools')
    .addItem('Expand job postings', 'expandJobs')
    .addToUi();
}

Watch out for

  • It only fills empty posting cells. To regenerate a posting, clear its cell first — otherwise the script skips that row.
  • The header names must match exactly. title, bullets, and posting are case-sensitive; a stray capital and the col lookup returns undefined.
  • A long list of roles can hit the six-minute execution limit, since each row is a separate API call. For a big batch, process the sheet in slices.
  • The copy still needs a human read. Claude invents plausible “About us” and “What we offer” detail — check it matches Northwind’s real benefits and tone before the posting goes live.

Related