Batch content from a plan
Turn a content plan into a month of on-brand posts — dozens of fills at a time.
Brainstorm one visual style with a user, then turn their content plan into dozens of posts in that style — automatically. This guide combines user-created templates with bounded-concurrency batch execution.
1. Prepare once
- A template the user has approved — created during onboarding or picked from candidates (see the user-created templates guide).
- A content plan — copy per post, from wherever it already lives: a CSV, your database, a Notion sync, or a spreadsheet someone hands you.
A minimal plan row: the copy to apply, and where the post is going (network/format). Anything else — product to feature, image to use — is optional gravy.
copy,format
"The Autumn Roast is here — notes of fig, cedar, and bonfire smoke.",instagram_post
"This weekend only: 30% off everything.",instagram_story
"Meet the roaster: 10 years of sourcing in one cup.",linkedin2. Fill in bounded parallel
Each post is one mechanical fill. Run them with a small worker pool — 4–8 concurrent requests keeps you friendly to rate limits and gives you clean per-post error handling:
import { readFile } from "node:fs/promises";
import { parse } from "csv-parse/sync"; // install csv-parse, or use your CSV/db layer
const rows = parse(await readFile("content-plan.csv"), { columns: true }) as {
copy: string;
format: string;
}[];
const SIZES: Record<string, string> = {
instagram_post: "1080x1350",
instagram_story: "1080x1920",
linkedin: "1200x627",
};
const fill = (row: { copy: string; format: string }) =>
fetch("https://api.rivermark.ai/design", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RIVERMARK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
task: `Create a ${row.format.replace(/_/g, " ")} from this template with the following copy: "${row.copy}"`,
references: [{ type: "design", id: template.designId }],
creativity: 0, // mechanical fills
image_source: "none",
output: [{ id: "post", size: SIZES[row.format] }],
}),
})
.then((r) => r.json())
.catch((error) => ({ error, row })); // don't fail the batch on one post
// Bounded-concurrency map with 6 workers
const posts = await mapWithConcurrency(rows, fill, 6);
const failed = posts.filter((p) => "error" in p);
console.log(`${posts.length - failed.length} created, ${failed.length} failed`);
async function mapWithConcurrency<T, R>(items: T[], fn: (item: T) => Promise<R>, limit: number): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
await Promise.all(
Array.from({ length: limit }, async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}),
);
return results;
}Two details that matter:
- Per-post error isolation. One bad row shouldn't kill the batch. Collect failures, show them to the user, offer "retry" (it's just another fill).
- Download as you go. Rendered URLs are ephemeral, so persist each post's outputs when its fill completes — don't wait for the batch to finish. Store the
design_idalongside the saved files: it's what you'll use for the inevitable "can we tweak Tuesday's post?" (a conversational edit).
3. Present and schedule
Give the user a grid of the month's posts: thumbnails from the saved renders, drag-to-reorder, per-post edit buttons, and a schedule button that fires your normal publishing flow. The edit button is where the batch loop pays off again:
{
"task": "Apply the user's requested changes to the design. Do not change anything else.",
"user_prompt": "swap the photo for the other roastery shot",
"continue_from": "rmd_cj4hbqz8k0008mz6x7p9q2xv"
}A tweak affects only that post — the template and every other post in the batch are untouched, because every design is independent.
Variations
Not-a-plan inputs. The "plan" can be anything enumerable: a product feed, a list of upcoming events, the user's recent blog posts (fetch with allow_fetch). If you can turn it into rows, you can batch it.
Progressive generation. For large plans (hundreds of rows), generate the first 12, let the user sanity-check style, then run the rest — catching a systematic misunderstanding after 12 cheap fills beats catching it after 300.
Economics
Filling a template with provided copy is the cheapest meaningful operation in the API, so a month of content costs very little on top of the one-time template creation and brand extraction — see Costs. That's the shape of the value proposition: extract once, template rarely, fill forever.

