RivermarkRivermark
Guides

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.

content-plan.csv
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.",linkedin

2. 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:

batch.ts
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_id alongside 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:

The inevitable edit
{
  "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.

On this page