Automating Event Reminders with Ghost and Warp

I use the Ghost blogging platform to organize monthly potlucks. I like that the same post can both published to the site and to email. Ghost also supports scheduled posts and has a "duplicate post" feature that makes it easy to use a post as a template.
Today I had an amazing experience automating scheduling posts the next six months of potlucks. This included scheduling six monthly announcement posts, each with a custom photo and video, and then also setting up reminder emails for each of these, closer to the time of the event.
All I had to do was to collect the dates, photo file names and video URLs I wanted to use in a spreadsheet and the rest was largely handled by automation.
Let's walk through how AI was used to automate these steps.
Warp, the intelligent terminal
Central my success was Warp, which bills itself as an intelligent terminal. While familiarity with the terminal helps understand what's going on, the reality is that with Warp you can describe what you want to do in natural language and it will handle it for you. Warp uses confirm prompts to get your explicit permission to run certain commands.
I like that Warp allows you to try the AI features without any account at all. then Warp offer some more AI credits to use when you sign up, but still with a free account. And then from there they have some reasonable flat-rate monthly accounts. I used a paid account for this, which was a good investment in time saved. Enough telling, let's show!
After finding a selection of photos from my phone I wanted to use, the first task that I usually do manually is converting photos from the HEIC format to JPEG format and then name them to match the month. The simple, consistent names make the next automation step easier. I handed this off to Warp. You can see I typed into Warp in natural language what I wanted to do.

Next, I like to optimize my images so they download faster and take up less space on my server. I handed off this batch job to Warp as well. Here you can see that a normal shell command, one I created called last10
which lists the last 10 files in a directory. Warp recognizes the difference between normal terminal commands and AI prompts and responds accordingly.

I'm familiar with Ghost and knew it has an admin Application Programming Interface (API) that makes it easy for other software to integrate with it and do thing like automate post creation. I suspected if I gave Warp an API key and some instructions, it could handle automating post creation and I was right. In the Ghost admin area, there's a place for custom integrations where a new API key is easily created and given a name.
The other part I prepared through the Ghost admin interface was updating my template post to be prepared for automation by using syntax like {{date}} and {{video}} where I wanted elements replaced in the post. Then I grabbed the ID of the my template post from the URL so I could give it to Ghost. Finally, I was ready to prompt Warp on what I wanted it to do. What I'm sharing here is a variation of what I posted that is more likely to work on the first try. On my first attempt, I forgot to consider time zones and forgot to recommend using the official Ghost Admin client library. Here's the prompt:
The big picture is that we are going to use the Ghost Admin API to automate
creating a series of scheduled posts and reminders for those posts. For
this task, you will need the following context.
You will need to be an expert in the Ghost Admin API, which is documented here: https://ghost.org/docs/admin-api
I recommend using the official JavaScript Ghost API client.
You need my Ghost Admin API key, which is: REDACTED
My API Base URL domain is https://example.com This is a production site, so
be careful.
The template we will be using for these newsletters has the following post ID.
You can use the Ghost Admin API to look up the details:
64cfe32331c74a31fdcdf757
For each the six remaining months of the year, we will create a post which will be scheduled to out at noon before two weeks before the event date. This will
be scheduled to publish the website and also scheduled publish to email.
Two days before the event, we will send out a reminder. To create these, we will duplicate the post for that month and add a prefix to the title: "REMINDER: "
Reminder posts are scheduled to go out to email ONLY two days before the event at noon. They are NOT published to the website.
Data you need for this job is located in a file in this directory named
"2025-second-half.csv". Here is the documentation for the columns:
date - This is the event date
photo - Name of file in this directory to use as the feature photo.
video - URL of a video to embed. Be sure the video appears as an embed using a Ghost YouTube or video card and is not a bare link.
Use JavaScript to calculate the dates two day before and two weeks before the event date.
In the template post I mentioned, 64cfe32331c74a31fdcdf757 You'll be replacing the feature image with the photos from the CSV. Also, where it says {{date}}
will be replaced with for the event date, and {{video}} is where the video embed will appear.
To start with, first I only want to build the newsletter for July. I'll approve that and then next we'll do the reminder for July. Once that's approved, you can work on creating the rest of the newsletters.
Remember that I use the Fish shell and that times I mentioned should be in UTC-4.
Do you have any questions before we start?
And from there we were off to the races. After previewing the first month, I spotted the time zone and guessed that another problem would be solved by using the official API client. Tip: Before automating anything, run it once and check that it works before running it many times! After reviewing a successful second attempt and a successful reminder email, I gave Warp permission to finish automating the rest.
Warp is different than the LLM chat apps that run in your browser because it has access to your laptop to write and run code there, allowing for more powerful automation.
To wrap up, I asked Warp to create a general purpose version of the tool for other people to use to automate their own Ghost newsletters. Now, I have tested this version myself, so use it with caution and leave a comment below with your results.
Note the prerequisites, install and usage details noted in the comments!
For more custom Ghost content automation, consider chatting with Warp yourself!
I ran this experiment on Linux. Mac and Windows both support similar terminal environments, but there may be more setup steps, like installing the Node.js language or a tool for image conversion, but Warp can help with that, too.
ghost-newsletter-automation.js
#!/usr/bin/env node
/**
* Ghost Newsletter Automation Script
*
* This script automates the creation of scheduled newsletter posts and reminder emails
* for recurring events using the Ghost Admin API. It reads event data from a CSV file
* and creates two posts per event:
* 1. A main newsletter (published to website + email) sent 2 weeks before the event
* 2. A reminder email (email-only) sent 2 days before the event
*
* Prerequisites:
* - Node.js installed
* - Ghost Admin API key set as environment variable
* - CSV file with event data (date, photo, video columns)
* - YAML configuration file (newsletter-config.yaml)
* - Template post in Ghost with {{date}} and {{video}} placeholders
*
* Installation:
* npm install @tryghost/admin-api csv-parse yaml
*
* Usage:
* node ghost-newsletter-automation.js [config-file]
*
* Configuration:
* All settings are configured via YAML file (default: newsletter-config.yaml)
* Set GHOST_API_KEY environment variable with your Ghost Admin API key
*
* CSV Format:
* date,photo,video
* 7/20/2025,2025-07-newsletter-photo.jpg,https://www.youtube.com/watch?v=...
* 8/17/2025,2025-08-newsletter-photo.jpg,https://www.youtube.com/watch?v=...
*
* Author: Generated for PEA Pod newsletter automation
* License: MIT
*/
const GhostAdminAPI = require('@tryghost/admin-api');
const { parse } = require('csv-parse/sync');
const fs = require('fs');
const YAML = require('yaml');
const path = require('path');
// Default configuration file
const DEFAULT_CONFIG_FILE = 'newsletter-config.yaml';
/**
* Load and validate configuration from YAML file
*/
function loadConfig(configFile = DEFAULT_CONFIG_FILE) {
try {
const configPath = path.resolve(configFile);
if (!fs.existsSync(configPath)) {
throw new Error(`Configuration file not found: ${configPath}`);
}
const configContent = fs.readFileSync(configPath, 'utf8');
const config = YAML.parse(configContent);
// Validate required configuration
const required = [
'ghost.url',
'ghost.api_key_env_var',
'organization.name',
'organization.event_type',
'content.template_post_id',
'files.csv_file'
];
for (const key of required) {
const value = key.split('.').reduce((obj, k) => obj?.[k], config);
if (!value) {
throw new Error(`Missing required configuration: ${key}`);
}
}
return config;
} catch (error) {
console.error('Failed to load configuration:', error.message);
process.exit(1);
}
}
/**
* Initialize Ghost Admin API client
*/
function initializeGhostAPI(config) {
const apiKey = process.env[config.ghost.api_key_env_var];
if (!apiKey) {
throw new Error(`Environment variable ${config.ghost.api_key_env_var} not set`);
}
return new GhostAdminAPI({
url: config.ghost.url,
key: apiKey,
version: config.ghost.api_version || 'v5.0'
});
}
/**
* Parse CSV file containing event data
*/
function parseEventCSV(config) {
try {
const csvPath = path.resolve(config.files.csv_file);
if (!fs.existsSync(csvPath)) {
throw new Error(`CSV file not found: ${csvPath}`);
}
const csvContent = fs.readFileSync(csvPath, 'utf8');
const records = parse(csvContent, {
columns: true,
skip_empty_lines: true,
trim: true
});
if (config.advanced.verbose) {
console.log(`Loaded ${records.length} events from CSV`);
}
return records;
} catch (error) {
console.error('Failed to parse CSV file:', error.message);
process.exit(1);
}
}
/**
* Calculate date with offset (days before/after)
*/
function calculateDate(dateStr, daysOffset = 0) {
const [month, day, year] = dateStr.split('/');
const date = new Date(year, month - 1, day); // month is 0-indexed
date.setDate(date.getDate() + daysOffset);
return date;
}
/**
* Format date for Ghost API scheduling
*/
function formatDateForGhost(date, config) {
const [hours, minutes] = config.scheduling.send_time.split(':');
const utcHours = parseInt(hours) - config.scheduling.timezone_offset;
// Create date in UTC
const utcDate = new Date(date);
utcDate.setUTCHours(utcHours, parseInt(minutes), 0, 0);
return utcDate.toISOString();
}
/**
* Get month name from date
*/
function getMonthName(date) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return months[date.getMonth()];
}
/**
* Generate feature image URL with current year/month
*/
function generateFeatureImageURL(config, photoFilename) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const baseUrl = config.ghost.url.replace(/\/$/, ''); // Remove trailing slash
return `${baseUrl}/content/images/${year}/${month}/${photoFilename}`;
}
/**
* Replace placeholders in template content
*/
function replaceTemplatePlaceholders(templateLexical, event, config) {
const eventDate = calculateDate(event[config.files.csv_columns.date]);
const formattedDate = `${getMonthName(eventDate)} ${eventDate.getDate()}, ${eventDate.getFullYear()}`;
// Replace {{date}} placeholder
let modifiedLexical = templateLexical.replace(/\{\{date\}\}/g, formattedDate);
// Replace {{video}} placeholder with embed card
const videoUrl = event[config.files.csv_columns.video];
const embedCard = {
"type": "embed",
"version": 1,
"url": videoUrl,
"html": null,
"metadata": {
"url": videoUrl,
"type": "video",
"title": config.content.default_video_title,
"description": "",
"author": null,
"publisher": null,
"thumbnail": null,
"icon": null
}
};
// Parse lexical JSON and replace video placeholder
let lexicalObj = JSON.parse(modifiedLexical);
function replaceVideoPlaceholder(obj) {
if (Array.isArray(obj)) {
return obj.map(replaceVideoPlaceholder);
} else if (obj && typeof obj === 'object') {
const newObj = {};
for (const [key, value] of Object.entries(obj)) {
if (key === 'text' && value === '{{video}}') {
return embedCard;
} else {
newObj[key] = replaceVideoPlaceholder(value);
}
}
return newObj;
}
return obj;
}
lexicalObj = replaceVideoPlaceholder(lexicalObj);
return JSON.stringify(lexicalObj);
}
/**
* Create newsletter and reminder posts for a single event
*/
async function createEventPosts(api, event, templatePost, config) {
const eventDate = calculateDate(event[config.files.csv_columns.date]);
const monthName = getMonthName(eventDate);
const year = eventDate.getFullYear();
// Calculate scheduling dates
const newsletterDate = calculateDate(event[config.files.csv_columns.date], -config.scheduling.newsletter_days_before);
const reminderDate = calculateDate(event[config.files.csv_columns.date], -config.scheduling.reminder_days_before);
// Process template content
const processedLexical = replaceTemplatePlaceholders(templatePost.lexical, event, config);
const featureImageUrl = generateFeatureImageURL(config, event[config.files.csv_columns.photo]);
const results = {};
if (config.advanced.create_posts) {
// Create main newsletter
const newsletterPost = {
title: `${monthName} ${year}, ${config.organization.name} ${config.organization.event_type}`,
lexical: processedLexical,
feature_image: featureImageUrl,
status: 'scheduled',
published_at: formatDateForGhost(newsletterDate, config),
email_segment: config.content.email_segment,
visibility: 'public',
featured: false
};
if (config.advanced.verbose) {
console.log(`Creating ${monthName} newsletter...`);
}
results.newsletter = await api.posts.add(newsletterPost);
// Create reminder email
const reminderPost = {
title: `REMINDER: ${monthName} ${year}, ${config.organization.name} ${config.organization.event_type}`,
lexical: processedLexical,
feature_image: featureImageUrl,
status: 'scheduled',
published_at: formatDateForGhost(reminderDate, config),
email_segment: config.content.email_segment,
visibility: 'public',
email_only: true,
featured: false
};
if (config.advanced.verbose) {
console.log(`Creating ${monthName} reminder...`);
}
results.reminder = await api.posts.add(reminderPost);
} else {
// Dry run mode
results.newsletter = { id: 'DRY_RUN', title: `${monthName} ${year} Newsletter` };
results.reminder = { id: 'DRY_RUN', title: `${monthName} ${year} Reminder` };
}
return {
...results,
month: monthName,
year: year,
eventDate: `${monthName} ${eventDate.getDate()}, ${year}`,
newsletterScheduled: formatDateForGhost(newsletterDate, config),
reminderScheduled: formatDateForGhost(reminderDate, config)
};
}
/**
* Main execution function
*/
async function main() {
console.log('š Ghost Newsletter Automation Script\n');
try {
// Load configuration
const configFile = process.argv[2] || DEFAULT_CONFIG_FILE;
console.log(`š Loading configuration from: ${configFile}`);
const config = loadConfig(configFile);
if (config.advanced.verbose) {
console.log(`š¢ Organization: ${config.organization.name}`);
console.log(`š Ghost URL: ${config.ghost.url}`);
console.log(`š§ Send time: ${config.scheduling.send_time} (UTC${config.scheduling.timezone_offset >= 0 ? '+' : ''}${config.scheduling.timezone_offset})`);
}
// Initialize Ghost API
console.log('š Initializing Ghost API...');
const api = initializeGhostAPI(config);
// Load template post
console.log('š Loading template post...');
const templatePost = await api.posts.read(
{ id: config.content.template_post_id },
{ formats: ['lexical', 'html'] }
);
if (config.advanced.verbose) {
console.log(`ā
Template loaded: "${templatePost.title}"`);
}
// Parse events from CSV
console.log('š Parsing event data...');
const events = parseEventCSV(config);
if (!config.advanced.create_posts) {
console.log('š DRY RUN MODE - No posts will be created\n');
}
// Process each event
console.log(`\nš
Processing ${events.length} events...\n`);
const results = [];
for (const event of events) {
try {
const result = await createEventPosts(api, event, templatePost, config);
results.push(result);
console.log(`ā
${result.month} ${result.year} completed:`);
console.log(` š° Newsletter: ${result.newsletter.id}`);
console.log(` šØ Reminder: ${result.reminder.id}`);
console.log(` š
Event Date: ${result.eventDate}`);
console.log(` ā° Newsletter sends: ${result.newsletterScheduled}`);
console.log(` ā° Reminder sends: ${result.reminderScheduled}\n`);
} catch (error) {
console.error(`ā Error processing ${event[config.files.csv_columns.date]}:`, error.message);
}
}
// Final summary
console.log('\nš Newsletter automation completed successfully!\n');
console.log('š Summary:');
results.forEach(result => {
console.log(` ${result.month} ${result.year}: Newsletter (${result.newsletter.id}) + Reminder (${result.reminder.id})`);
});
if (!config.advanced.create_posts) {
console.log('\nš” This was a dry run. Set advanced.create_posts to true in config to create actual posts.');
}
} catch (error) {
console.error('\nš„ Fatal error:', error.message);
if (config?.advanced?.verbose && error.response) {
console.error('Response details:', error.response.data);
}
process.exit(1);
}
}
// Execute if run directly
if (require.main === module) {
main();
}
module.exports = {
loadConfig,
initializeGhostAPI,
parseEventCSV,
createEventPosts,
main
};
newsletter-config.yaml
# Ghost Newsletter Automation Configuration
# This file contains all the settings for automated newsletter creation
# Ghost CMS Configuration
ghost:
# Your Ghost site URL (without trailing slash)
url: "https://example.com"
# Your Ghost Admin API key (format: keyId:secret)
api_key_env_var: "GHOST_API_KEY"
# Ghost API version
api_version: "v5.0"
# Organization Details
organization:
# Name of your organization/group
name: "PEA Pod"
# Type of event (will appear in titles)
event_type: "Potluck"
# Scheduling Configuration
scheduling:
# Time zone offset from UTC (e.g., -4 for EDT, -5 for EST)
timezone_offset: -4
# Time of day to send newsletters (24-hour format)
send_time: "12:00"
# Days before event to send main newsletter
newsletter_days_before: 14
# Days before event to send reminder
reminder_days_before: 2
# Content Configuration
content:
# ID of the template post in Ghost
template_post_id: "64cfe32331c74a31fdcdf757"
# Default video title for embeds
default_video_title: "Plant-based recipe video"
# Email segment to send to (usually "all")
email_segment: "all"
# File Configuration
files:
# CSV file containing event data
csv_file: "events-simple.csv"
# Expected CSV columns
csv_columns:
date: "date" # Event date (MM/DD/YYYY format)
photo: "photo" # Photo filename
video: "video" # YouTube video URL
# Advanced Settings
advanced:
# Whether to actually create posts (false for dry run)
create_posts: false
# Verbose logging
verbose: true
Comments ()