appscript.dev
Automation Intermediate Docs

Spin a blog post into social captions

Generate platform-specific posts from one Northwind article — LinkedIn, X, Threads.

Published Aug 11, 2025

Northwind publishes a blog post, and then someone has to turn it into social captions — a polished LinkedIn version, a punchy line for X, something conversational for Threads. Each platform wants a different length and tone, so it is three separate writing jobs on top of the article itself. The captions usually get rushed, or skipped.

This function does the rewriting. Point it at the Google Doc that holds a blog post and it asks Claude for three captions in one pass — each tuned to its platform — and hands them back as structured data you can paste straight into a scheduler. The article ships with its captions already written.

What you’ll need

  • A blog post written in a Google Doc — you’ll pass its document ID to the function.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.
  • Nothing else — this is a single function you call with a doc ID.

The script

// Per-platform caption specs. Edit these to retune length or tone
// without touching the prompt-building code below.
const CAPTION_SPECS = [
  { key: 'linkedin', brief: 'One LinkedIn post (200 words, professional tone)' },
  { key: 'x', brief: 'One X post (270 chars, punchy)' },
  { key: 'threads', brief: 'One Threads post (300 chars, conversational)' },
];

/**
 * Reads a Northwind blog post from a Google Doc and asks Claude to
 * rewrite it as one caption per platform.
 *
 * @param {string} blogDocId - The Google Doc ID of the blog post.
 * @return {Object} An object keyed by platform: {linkedin, x, threads}.
 */
function spinSocialCaptions(blogDocId) {
  // 1. Pull the full text of the blog post out of the doc.
  const body = DocumentApp.openById(blogDocId).getBody().getText().trim();

  if (!body) {
    Logger.log('The doc is empty — nothing to spin.');
    return {};
  }

  // 2. Build a bulleted brief from the caption specs.
  const briefs = CAPTION_SPECS.map((s) => '- ' + s.brief).join('\n');

  // 3. Ask for strict JSON keyed by platform — one pass, three captions.
  const prompt =
    'Distil this Northwind blog post into:\n' + briefs + '\n\n' +
    'Return ONLY a JSON object — no prose, no markdown — in this shape: ' +
    '{"linkedin": string, "x": string, "threads": string}.\n\n' +
    body;

  // 4. Sonnet does the rewriting; strip any code fence, then parse.
  const reply = callClaude(prompt);
  const captions = JSON.parse(stripFences(reply));

  Logger.log('Generated captions for: ' + Object.keys(captions).join(', '));
  return captions;
}

/**
 * Claude occasionally wraps JSON in a ```json code fence. Strip it so
 * JSON.parse never chokes on the markdown.
 */
function stripFences(text) {
  return text.replace(/```(?:json)?/g, '').trim();
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
function callClaude(prompt, model = 'claude-sonnet-4-6', maxTokens = 800) {
  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. spinSocialCaptions opens the Google Doc by ID and pulls out its full text.
  2. If the doc is empty, it logs a message and returns an empty object — no wasted API call.
  3. It turns CAPTION_SPECS into a bulleted brief, so retuning a platform’s length or tone is a one-line edit at the top of the file.
  4. It builds a prompt that pins the output to a strict JSON object keyed by platform — linkedin, x, threads.
  5. It calls Claude Sonnet, which handles tone-shifting well. stripFences removes any code fence, then JSON.parse turns the reply into real objects.
  6. It returns the captions object so the caller can paste them, log them, or write them into a scheduling sheet.

Example run

Call it from the editor or another function with a doc ID:

const captions = spinSocialCaptions('1AbcBlogDocId');
Logger.log(captions.x);

For a blog post titled “Five ways to speed up Northwind onboarding”, the returned object looks like this:

PlatformCaption
linkedin”Onboarding is the first impression your product makes. We cut Northwind’s setup time in half with five small changes — clearer defaults, a guided first task, fewer required fields… (200 words)“
x”Slow onboarding loses customers before they ever start. 5 fixes that cut Northwind setup time in half 🧵“
threads”We timed our own onboarding and it was painful. Here’s what we changed — and why the smallest fix made the biggest difference.”

Three ready-to-schedule captions from one article, no extra writing session.

Run it

This is an on-demand job — you run it when a post is ready, not on a schedule:

  1. In the Apps Script editor, open a function that calls spinSocialCaptions with your doc ID, or call it from the editor’s console.
  2. Approve the authorisation prompt the first time.
  3. Read the captions from the log, or wire the return value into a sheet.

To make it self-serve from a doc, add a custom menu that prompts for the ID:

function onOpen() {
  DocumentApp.getUi()
    .createMenu('Northwind tools')
    .addItem('Spin social captions', 'runFromActiveDoc')
    .addToUi();
}

function runFromActiveDoc() {
  const captions = spinSocialCaptions(DocumentApp.getActiveDocument().getId());
  DocumentApp.getUi().alert(JSON.stringify(captions, null, 2));
}

Watch out for

  • Character limits are guidance, not guarantees. Claude usually respects “270 chars”, but check the X caption before posting — count it in code if the caption goes straight to an API that will reject an over-length post.
  • Very long articles can exceed the model’s input budget. For a long-form post, summarise it to its key points first, then spin captions from the summary.
  • Strict JSON keeps this reliable. stripFences handles the common case where Claude wraps the object in a code fence. If JSON.parse still throws, log the raw reply and tighten the prompt rather than reaching for regex.
  • The captions reflect the article’s tone. If the blog post is dry, the captions will be too — edit the platform briefs in CAPTION_SPECS to push for more energy if you need it.

Related