🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-06-20-oannes-carousel-routing.md
updated 2026-06-20

Oannes Carousel Format + Per-Platform Routing Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add image carousels as a second content format and route formats per platform (reel→FB/YT/TikTok, carousel→IG), rendering both from one claim per drop — without disturbing the working reel pipeline.

Architecture: Mirror the existing reel pipeline. A new carousel scriptwriter turns a claim into slide specs; a restyled Remotion CarouselSlide renders each to a PNG; a montage builds a Discord preview; publishCarousel uploads the slides and posts an IG carousel via Blotato. A configurable FORMAT_BY_PLATFORM map drives which format publishes where. The daily machine builds both artifacts; approvals publishes each format to its mapped platforms. Metrics gain a format dimension. Everything is additive and gated — a failure in one format never blocks the other.

Tech Stack: TypeScript (ESM/NodeNext — relative imports end in .js), vitest (tests/*.test.ts, inject fetch), zod, Remotion stills, claude --print shell-out, Blotato API, ffmpeg (montage tile).

Reference spec: docs/superpowers/specs/2026-06-20-oannes-carousel-routing-design.md


File Structure

File Responsibility New/Modify
src/publish/routing.ts Format type, FORMAT_BY_PLATFORM map, platformsForFormat() Create
src/script/carouselscript.ts claim+vibe → CarouselScript via claude --print; slidesFromScript() Create
src/render/Carousel.tsx restyle CarouselSlide to render by slide kind + optional bg image Modify
src/render/render.ts renderCarouselSlides() (replaces old renderCarousel‘s prop shape) Modify
src/render/index.tsx register CarouselSlide composition with new props (verify id) Modify (if needed)
src/media/montage.ts buildMontageCmd() (pure) + buildMontage() (ffmpeg tile) Create
src/publish/blotato.ts uploadImage() (mirror uploadVideo for PNG) Modify
src/publish/publishCarousel.ts upload N slides → IG carousel → poll/reconcile Create
src/auto/state.ts PendingItem.carousel? + results keep format Modify
src/auto/daily.ts also build carousel + montage; queue both Modify
src/auto/approvals.ts publish reel→reel-platforms + carousel→carousel-platforms Modify
src/metrics/stickiness.ts (unchanged — referenced)
src/metrics/store.ts add format column + field Modify
src/metrics/collect.ts tag snapshots with format Modify
src/metrics/analyze.ts group weights by (platform, format) Modify

Shared types (defined in routing.ts, imported everywhere):

export type Format = "reel" | "carousel";
export type Platform = "instagram" | "facebook" | "youtube" | "tiktok";

(Platform already exists in src/metrics/stickiness.ts; re-export it from there to avoid a duplicate — routing.ts imports Platform from ../metrics/stickiness.js and defines only Format.)


COMMIT 1 — Feasibility + routing

Files: none (manual verification; gates the publish design)

Run (in oannes-engine/):

node --input-type=module -e '
import "dotenv/config";
import { publishImagePost } from "./src/publish/blotato.js";
const r = await publishImagePost(
  { apiKey: process.env.BLOTATO_API_KEY, dryRun: true, platform: "instagram", accountId: "53213" },
  { caption: "test", hashtags: ["#x"], imageUrls: ["https://a/1.png","https://a/2.png","https://a/3.png"] },
);
console.log(r);
'

Expected: prints [DRY_RUN] would post to instagram listing all 3 image URLs. Confirms the array is sent.

Upload 2–3 real PNGs (any test images) via uploadImage once Task 7 exists, OR use existing Blotato-hosted URLs, and post a single real IG carousel. Inspect the live IG post: it must show swipe dots / multiple images.

If it posts only the first image (not a carousel): STOP and report. Fallback options in the spec (Blotato gallery field, or single stitched tall image) — pick with the user before continuing. Do NOT proceed to the daily-machine wiring on a broken publish path.

Add a one-line note in src/publish/publishCarousel.ts header comment (created in Task 8) stating the verified shape. No commit yet (no code).


Task 2: Routing map

Files: - Create: src/publish/routing.ts - Test: tests/routing.test.ts

// tests/routing.test.ts
import { describe, it, expect } from "vitest";
import { FORMAT_BY_PLATFORM, platformsForFormat } from "../src/publish/routing.js";

describe("routing", () => {
  it("maps each platform to one format (reel default, IG carousel)", () => {
    expect(FORMAT_BY_PLATFORM.facebook).toBe("reel");
    expect(FORMAT_BY_PLATFORM.youtube).toBe("reel");
    expect(FORMAT_BY_PLATFORM.tiktok).toBe("reel");
    expect(FORMAT_BY_PLATFORM.instagram).toBe("carousel");
  });
  it("platformsForFormat returns the platforms mapped to a format", () => {
    expect(platformsForFormat("carousel")).toEqual(["instagram"]);
    expect(platformsForFormat("reel").sort()).toEqual(["facebook", "tiktok", "youtube"]);
  });
});

Run: npx vitest run tests/routing.test.ts Expected: FAIL — cannot find module.

// src/publish/routing.ts
import type { Platform } from "../metrics/stickiness.js";

export type Format = "reel" | "carousel";

/** Which format publishes to which platform. Configurable: change a value to
 *  re-route (e.g. set tiktok to "carousel" to also send carousels there). */
export const FORMAT_BY_PLATFORM: Record<Platform, Format> = {
  facebook: "reel",
  youtube: "reel",
  tiktok: "reel",
  instagram: "carousel",
};

export function platformsForFormat(format: Format): Platform[] {
  return (Object.keys(FORMAT_BY_PLATFORM) as Platform[]).filter((p) => FORMAT_BY_PLATFORM[p] === format);
}

Run: npx vitest run tests/routing.test.ts Expected: PASS.

git add src/publish/routing.ts tests/routing.test.ts
git commit -m "feat(oannes-engine): per-platform format routing map"

Files: - Create: src/script/carouselscript.ts - Test: tests/carouselscript.test.ts

// tests/carouselscript.test.ts
import { describe, it, expect } from "vitest";
import { buildCarouselPrompt, parseCarouselScript, slidesFromScript } from "../src/script/carouselscript.js";
import type { ContentBrief } from "../src/types.js";

const brief: ContentBrief = {
  claimId: "c", format: "carousel",
  claim: { id: "c", claimText: "The CIA ran psychic spies who saw real targets.", claimant: "Lyn Buchanan",
    source: "Ep", topics: ["remote-viewing"], status: "", dateDisplay: "", file: "" },
};

const sample = {
  hook: "The CIA paid psychics to spy.",
  body: ["A secret program ran for decades.", "Operators described distant targets.", "Some hits were uncanny."],
  attributionName: "Lyn Buchanan",
  attributionRole: "former military remote viewer",
  caption: "What did they really see? 👁️",
  hashtags: ["remoteviewing", "CIA", "Oannes"],
};

describe("buildCarouselPrompt", () => {
  it("demands attribution + fenced JSON and includes the claim", () => {
    const p = buildCarouselPrompt(brief, "believer");
    expect(p).toMatch(/```json/);
    expect(p).toMatch(/ATTRIBUTED/);
    expect(p).toContain("psychic spies");
  });
});

describe("parseCarouselScript", () => {
  it("parses a fenced JSON payload", () => {
    const raw = "x\n```json\n" + JSON.stringify(sample) + "\n```";
    const s = parseCarouselScript(raw);
    expect(s.hook).toBe("The CIA paid psychics to spy.");
    expect(s.body.length).toBe(3);
    expect(s.attributionName).toBe("Lyn Buchanan");
  });
  it("throws when attribution name is missing", () => {
    const bad = { ...sample, attributionName: "" };
    expect(() => parseCarouselScript("```json\n" + JSON.stringify(bad) + "\n```")).toThrow();
  });
});

describe("slidesFromScript", () => {
  it("builds hook + body + attribution + cta slides with 1-based index strings", () => {
    const slides = slidesFromScript(parseCarouselScript("```json\n" + JSON.stringify(sample) + "\n```"));
    // 1 hook + 3 body + 1 attribution + 1 cta = 6
    expect(slides.length).toBe(6);
    expect(slides[0]).toMatchObject({ kind: "hook", text: sample.hook, index: "1/6" });
    expect(slides[4]).toMatchObject({ kind: "attribution", name: "Lyn Buchanan", role: "former military remote viewer" });
    expect(slides[5].kind).toBe("cta");
  });
});

Run: npx vitest run tests/carouselscript.test.ts Expected: FAIL — cannot find module.

// src/script/carouselscript.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import type { ContentBrief } from "../types.js";
import type { Vibe } from "./reelscript.js";
import type { RunClaude } from "./scriptwriter.js";

const execFileP = promisify(execFile);

export interface CarouselScript {
  hook: string;
  body: string[];
  attributionName: string;
  attributionRole: string;
  caption: string;
  hashtags: string[];
}

const Schema = z.object({
  hook: z.string().min(1),
  body: z.array(z.string().min(1)).min(2).max(4),
  attributionName: z.string().min(1, "attribution name required"),
  attributionRole: z.string().min(1),
  caption: z.string().min(1),
  hashtags: z.array(z.string()).min(3),
});

export function buildCarouselPrompt(brief: ContentBrief, vibe: Vibe): string {
  const c = brief.claim;
  const tone = vibe === "wonder"
    ? "REFLECTIVE AWE & WONDER — profound, invites the mystery."
    : "PROVOCATIVE & GRIPPING — bold, edge-of-seat intrigue, never stated as settled fact.";
  return [
    "You write a 6-slide INSTAGRAM CAROUSEL for a UFO/mysteries channel called Oannes.",
    `TONE: ${tone}`,
    "HARD RULES:",
    "- The claim MUST be ATTRIBUTED to the person who made it. Never assert a contested claim as fact.",
    "- Never fabricate. Paraphrase; don't copy source wording.",
    "- No serious criminal accusations against named living private individuals.",
    "",
    `CLAIM: ${c.claimText}`,
    `CLAIMANT (attribute to this person): ${c.claimant}`,
    `TOPICS: ${c.topics.join(", ")}`,
    "",
    "Slide 1 is a punchy curiosity-gap HOOK (<= 12 words) that makes someone swipe.",
    "Slides 2-4 (the `body`) each reveal one beat of the claim (<= 14 words each).",
    "Provide the caption (1-2 emoji, ends with a question) and 3-7 hashtags.",
    'Return ONLY JSON inside a ```json fenced block:',
    '{"hook":"...","body":["...","...","..."],"attributionName":"<person>","attributionRole":"<short role>","caption":"...","hashtags":["...3-7..."]}',
  ].join("\n");
}

function parseFenced(text: string): unknown {
  const m = text.match(/```json\s*([\s\S]*?)```/i) ?? text.match(/```\s*([\s\S]*?)```/);
  return JSON.parse((m ? m[1] : text).trim());
}

export function parseCarouselScript(raw: string): CarouselScript {
  return Schema.parse(parseFenced(raw)) as CarouselScript;
}

export type SlideSpec =
  | { kind: "hook"; text: string; index: string; bgImage?: string }
  | { kind: "body"; text: string; index: string; bgImage?: string }
  | { kind: "attribution"; name: string; role: string; index: string; bgImage?: string }
  | { kind: "cta"; index: string; bgImage?: string };

export function slidesFromScript(s: CarouselScript): SlideSpec[] {
  const parts: Omit<SlideSpec, "index">[] = [
    { kind: "hook", text: s.hook },
    ...s.body.map((t) => ({ kind: "body" as const, text: t })),
    { kind: "attribution", name: s.attributionName, role: s.attributionRole },
    { kind: "cta" },
  ];
  const n = parts.length;
  return parts.map((p, i) => ({ ...p, index: `${i + 1}/${n}` })) as SlideSpec[];
}

const runClaudeCli: RunClaude = async (prompt) => {
  const { stdout } = await execFileP("claude", ["--print", prompt], { maxBuffer: 10 * 1024 * 1024 });
  return stdout;
};

export async function writeCarouselScript(brief: ContentBrief, vibe: Vibe, run: RunClaude = runClaudeCli): Promise<CarouselScript> {
  return parseCarouselScript(await run(buildCarouselPrompt(brief, vibe)));
}

Run: npx vitest run tests/carouselscript.test.ts Expected: PASS.

git add src/script/carouselscript.ts tests/carouselscript.test.ts
git commit -m "feat(oannes-engine): carousel scriptwriter (claim -> slides)"

COMMIT 3 — Render slides + montage

Task 4: Restyle the slide component to render by kind + optional bg image

Files: - Modify: src/render/Carousel.tsx

// src/render/Carousel.tsx
import React from "react";
import { AbsoluteFill, Img } from "remotion";

export interface SlideProps {
  kind: "hook" | "body" | "attribution" | "cta";
  text?: string;
  name?: string;
  role?: string;
  index: string;
  bgImage?: string;
  [key: string]: unknown;
}

const GRADE = "linear-gradient(170deg,#06080f 0%,#0d1b2a 55%,#1b1340 100%)";

export const CarouselSlide: React.FC<SlideProps> = ({ kind, text, name, role, index, bgImage }) => (
  <AbsoluteFill style={{ background: GRADE, fontFamily: "Outfit, sans-serif", color: "#fff" }}>
    {bgImage ? (
      <Img src={bgImage} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.4 }} />
    ) : null}
    <AbsoluteFill style={{ background: "rgba(4,6,12,0.45)" }} />
    <div style={{ position: "absolute", top: 70, left: 80, fontFamily: "Archivo Black, sans-serif", fontSize: 34, color: "#56dbd0", letterSpacing: 2 }}>OANNES CODE</div>
    <div style={{ position: "absolute", top: 74, right: 80, fontSize: 32, color: "#8a8aa0", fontWeight: 700 }}>{index}</div>

    <AbsoluteFill style={{ padding: 90, justifyContent: "center" }}>
      {kind === "hook" && (
        <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 96, lineHeight: 1.08, textTransform: "uppercase" }}>{text}</div>
      )}
      {kind === "body" && (
        <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 76, lineHeight: 1.15 }}>{text}</div>
      )}
      {kind === "attribution" && (
        <div>
          <div style={{ fontSize: 40, color: "#56dbd0", letterSpacing: 4, textTransform: "uppercase", marginBottom: 24 }}>The source</div>
          <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 84 }}>{name}</div>
          <div style={{ fontSize: 46, color: "#c8c8da", marginTop: 18 }}>{role}</div>
        </div>
      )}
      {kind === "cta" && (
        <div style={{ textAlign: "center" }}>
          <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 88, textTransform: "uppercase" }}>Follow for more</div>
          <div style={{ fontSize: 52, color: "#56dbd0", marginTop: 28 }}>@oannescode</div>
        </div>
      )}
    </AbsoluteFill>
  </AbsoluteFill>
);

Run: npm run typecheck Expected: passes (the old kicker/headline/sub props are gone; the next task updates the only caller).

git add src/render/Carousel.tsx
git commit -m "feat(oannes-engine): restyle carousel slide by kind + bg image"

Task 5: renderCarouselSlides + composition props

Files: - Modify: src/render/render.ts (replace carouselSlideProps/renderCarousel) - Modify: src/render/index.tsx (ensure CarouselSlide composition default props match new shape)

Run: grep -n "CarouselSlide\|defaultProps" src/render/index.tsx If CarouselSlide is registered with old kicker/headline/sub defaultProps, update its defaultProps to { kind: "hook", text: "", index: "1/6" } so selectComposition validates. (Composition id stays "CarouselSlide".)

Remove carouselSlideProps and renderCarousel; add:

import type { SlideSpec } from "../script/carouselscript.js";

/** Render each carousel slide spec to a PNG (1080x1350). Returns file paths in order. */
export async function renderCarouselSlides(slides: SlideSpec[], outDir: string): Promise<string[]> {
  const serveUrl = await getBundle();
  const out: string[] = [];
  for (let i = 0; i < slides.length; i++) {
    const inputProps = slides[i] as unknown as Record<string, unknown>;
    const composition = await selectComposition({ serveUrl, id: "CarouselSlide", inputProps });
    const file = `${outDir}/slide-${i + 1}.png`;
    await renderStill({ composition, serveUrl, output: file, inputProps });
    out.push(file);
  }
  return out;
}

Also remove the now-unused import type { SlideProps } if carouselSlideProps was its only user (run typecheck to confirm).

Run: npm run typecheck Expected: passes. (If render.test.ts referenced renderCarousel, update it to call renderCarouselSlides with a SlideSpec[] and a tmpdir — keep the test mock-light; it only needs to assert the return shape if it already mocks Remotion.)

git add src/render/render.ts src/render/index.tsx tests/render.test.ts
git commit -m "feat(oannes-engine): renderCarouselSlides from slide specs"

Task 6: Montage (Discord preview contact-sheet)

Files: - Create: src/media/montage.ts - Test: tests/montage.test.ts

// tests/montage.test.ts
import { describe, it, expect } from "vitest";
import { buildMontageArgs } from "../src/media/montage.js";

describe("buildMontageArgs", () => {
  it("tiles N input PNGs into one row-wrapped image at a downscaled size", () => {
    const args = buildMontageArgs(["a.png", "b.png", "c.png"], "out.png");
    // each input present
    expect(args.filter((a) => a === "-i")).toHaveLength(3);
    expect(args).toContain("a.png");
    expect(args).toContain("out.png");
    // uses ffmpeg tile filter referencing the count (3 -> tile=3xN)
    expect(args.join(" ")).toMatch(/tile=/);
  });
  it("throws on empty input", () => {
    expect(() => buildMontageArgs([], "out.png")).toThrow();
  });
});

Run: npx vitest run tests/montage.test.ts Expected: FAIL — cannot find module.

// src/media/montage.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileP = promisify(execFile);

/** Build ffmpeg args to tile slide PNGs into one contact-sheet image (3 per row). */
export function buildMontageArgs(slidePaths: string[], outPath: string): string[] {
  if (slidePaths.length === 0) throw new Error("montage needs at least one slide");
  const cols = Math.min(3, slidePaths.length);
  const rows = Math.ceil(slidePaths.length / cols);
  const args: string[] = ["-y"];
  for (const p of slidePaths) args.push("-i", p);
  // scale each to 360 wide, stack into a grid, pad the last row
  const filter = slidePaths.map((_, i) => `[${i}:v]scale=360:-1[s${i}]`).join(";") +
    ";" + slidePaths.map((_, i) => `[s${i}]`).join("") +
    `xstack=inputs=${slidePaths.length}:layout=` +
    slidePaths.map((_, i) => `${(i % cols)}_${Math.floor(i / cols)}`).join("|") +
    `:fill=black[out]`;
  // xstack needs equal-size inputs; for an MVP a simpler tile via the `tile` filter on a concat is more robust:
  const tileFilter = `[0:v]scale=360:-1,tile=${cols}x${rows}:padding=8:color=black[out]`;
  args.length = 1; // reset to ["-y"]; use the concat+tile approach below
  for (const p of slidePaths) args.push("-i", p);
  args.push("-filter_complex",
    slidePaths.map((_, i) => `[${i}:v]scale=360:450:force_original_aspect_ratio=decrease,pad=360:450:(ow-iw)/2:(oh-ih)/2:black[t${i}]`).join(";") +
    ";" + slidePaths.map((_, i) => `[t${i}]`).join("") + `concat=n=${slidePaths.length}:v=1:a=0[cat];[cat]tile=${cols}x${rows}:padding=8:color=black[out]`,
    "-map", "[out]", outPath);
  void tileFilter; void filter;
  return args;
}

export async function buildMontage(slidePaths: string[], outPath: string): Promise<string> {
  await execFileP("ffmpeg", buildMontageArgs(slidePaths, outPath), { maxBuffer: 10 * 1024 * 1024 });
  return outPath;
}

Note: the test only asserts args structure (inputs present, tile= in the command, throws on empty). The exact filter graph is refined against a real render in Task 11’s manual check; keep the test asserting structure, not the precise filter string.

Run: npx vitest run tests/montage.test.ts Expected: PASS.

git add src/media/montage.ts tests/montage.test.ts
git commit -m "feat(oannes-engine): slide montage for Discord carousel preview"

Task 7: uploadImage (mirror uploadVideo for PNG)

Files: - Modify: src/publish/blotato.ts - Test: tests/blotato-uploadimage.test.ts

// tests/blotato-uploadimage.test.ts
import { describe, it, expect } from "vitest";
import { uploadImage } from "../src/publish/blotato.js";

describe("uploadImage", () => {
  it("POSTs to media/uploads then PUTs the bytes with an image content-type, returns publicUrl", async () => {
    const calls: string[] = [];
    const f = (async (url: string, init?: RequestInit) => {
      calls.push(String(url) + ":" + (init?.method ?? "GET"));
      if (String(url).endsWith("/media/uploads")) {
        return { ok: true, json: async () => ({ presignedUrl: "https://put.here/x", publicUrl: "https://pub/x.png" }) } as Response;
      }
      return { ok: true } as Response; // the PUT
    }) as unknown as typeof fetch;
    // pass a tiny buffer reader by pointing at this test file (bytes don't matter to the mock)
    const url = await uploadImage("KEY", import.meta.url.replace("file://", ""), f);
    expect(url).toBe("https://pub/x.png");
    expect(calls.some((c) => c.includes("/media/uploads:POST"))).toBe(true);
    expect(calls.some((c) => c.includes("https://put.here/x:PUT"))).toBe(true);
  });
});

Run: npx vitest run tests/blotato-uploadimage.test.ts Expected: FAIL — uploadImage not exported.

/** Upload a local image (PNG) to Blotato; returns the public URL for mediaUrls. */
export async function uploadImage(apiKey: string, localPath: string, f: typeof fetch = fetch): Promise<string> {
  const fs = await import("node:fs/promises");
  const path = await import("node:path");
  const up = await f("https://backend.blotato.com/v2/media/uploads", {
    method: "POST",
    headers: { "blotato-api-key": apiKey, "content-type": "application/json" },
    body: JSON.stringify({ filename: path.basename(localPath) }),
  });
  if (!up.ok) throw new Error(`media/uploads failed: HTTP ${up.status}`);
  const { presignedUrl, publicUrl } = (await up.json()) as { presignedUrl: string; publicUrl: string };
  const bytes = await fs.readFile(localPath);
  const put = await f(presignedUrl, { method: "PUT", headers: { "content-type": "image/png" }, body: bytes });
  if (!put.ok) throw new Error(`presigned PUT failed: HTTP ${put.status}`);
  return publicUrl;
}

Run: npx vitest run tests/blotato-uploadimage.test.ts Expected: PASS.

git add src/publish/blotato.ts tests/blotato-uploadimage.test.ts
git commit -m "feat(oannes-engine): uploadImage for Blotato (carousel slides)"

Task 8: publishCarousel

Files: - Create: src/publish/publishCarousel.ts - Test: tests/publishcarousel.test.ts

// tests/publishcarousel.test.ts
import { describe, it, expect } from "vitest";
import { buildCarouselBody } from "../src/publish/publishCarousel.js";

describe("buildCarouselBody", () => {
  it("builds an IG post body with all slide image URLs and capped hashtags", () => {
    const body = buildCarouselBody("53213", "caption here", ["a", "b", "c", "d", "e", "f"], ["u1", "u2", "u3"]);
    expect(body.post.accountId).toBe("53213");
    expect(body.post.target.targetType).toBe("instagram");
    expect(body.post.content.mediaUrls).toEqual(["u1", "u2", "u3"]);
    // IG hashtag cap = 5
    expect((body.post.content.text.match(/#/g) || []).length).toBe(5);
  });
});

Run: npx vitest run tests/publishcarousel.test.ts Expected: FAIL — cannot find module.

// src/publish/publishCarousel.ts
// Verified (Task 1): Blotato posts content.mediaUrls[] to instagram as a native carousel.
import { uploadImage, formatPostText } from "./blotato.js";

export const IG_ACCOUNT = "53213";

export interface CarouselBody {
  post: { accountId: string; target: { targetType: "instagram"; mediaType?: string }; content: { text: string; mediaUrls: string[] } };
}

/** Build the Blotato IG carousel post body (IG hashtag cap = 5). */
export function buildCarouselBody(accountId: string, caption: string, hashtags: string[], imageUrls: string[]): CarouselBody {
  return {
    post: {
      accountId,
      target: { targetType: "instagram", mediaType: "carousel" },
      content: { text: formatPostText(caption, hashtags.slice(0, 5)), mediaUrls: imageUrls },
    },
  };
}

export interface CarouselResult { ok: boolean; url?: string; error?: string }

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** Upload all slides, publish as one IG carousel, poll to a live URL. */
export async function publishCarousel(
  apiKey: string, slidePaths: string[], caption: string, hashtags: string[], f: typeof fetch = fetch,
): Promise<CarouselResult> {
  try {
    const imageUrls: string[] = [];
    for (const p of slidePaths) imageUrls.push(await uploadImage(apiKey, p, f));
    const res = await f("https://backend.blotato.com/v2/posts", {
      method: "POST",
      headers: { "blotato-api-key": apiKey, "content-type": "application/json" },
      body: JSON.stringify(buildCarouselBody(IG_ACCOUNT, caption, hashtags, imageUrls)),
    });
    const txt = await res.text();
    if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${txt.slice(0, 200)}` };
    const sid = (JSON.parse(txt) as { postSubmissionId?: string }).postSubmissionId;
    if (!sid) return { ok: false, error: "no submission id" };
    for (let i = 0; i < 36; i++) {
      await sleep(8000);
      const d = (await (await f(`https://backend.blotato.com/v2/posts/${sid}`, { headers: { "blotato-api-key": apiKey } })).json().catch(() => ({}))) as { status?: string; publicUrl?: string; errorMessage?: string };
      if (d.status === "published") return { ok: true, url: d.publicUrl };
      if (d.status === "failed") return { ok: false, error: d.errorMessage ?? "failed" };
    }
    return { ok: false, error: "timed out waiting for publish" };
  } catch (e) {
    return { ok: false, error: String(e).slice(0, 200) };
  }
}

Run: npx vitest run tests/publishcarousel.test.ts Expected: PASS.

git add src/publish/publishCarousel.ts tests/publishcarousel.test.ts
git commit -m "feat(oannes-engine): publishCarousel (IG multi-image via Blotato)"

COMMIT 5 — Wire into the daily machine + metrics format dimension

Files: - Modify: src/auto/state.ts

In PendingItem, change results and add a carousel field:

  // results now tag the format that produced each platform post:
  results?: { platform: string; ok: boolean; url?: string; error?: string; format?: "reel" | "carousel" }[];
  videoUrl?: string;
  hook?: string;
  topic?: string;
  // Carousel artifact built from the same claim (queued alongside the reel):
  carousel?: { slidePaths: string[]; caption: string; hashtags: string[] };

Run: npm run typecheck Expected: passes (fields are optional; existing items load unchanged).

git add src/auto/state.ts
git commit -m "feat(oannes-engine): PendingItem carries carousel + format-tagged results"

Files: - Modify: src/auto/daily.ts

In src/auto/daily.ts, after const previewPath = await compressForPreview(...) and before building item, add:

  // --- Carousel (same claim, best-effort: a failure here must NOT block the reel) ---
  let carousel: { slidePaths: string[]; caption: string; hashtags: string[] } | undefined;
  let montagePath: string | undefined;
  try {
    const { writeCarouselScript, slidesFromScript } = await import("../script/carouselscript.js");
    const { renderCarouselSlides } = await import("../render/render.js");
    const { buildMontage } = await import("../media/montage.js");
    const cscript = await writeCarouselScript(brief, vibe);
    const specs = slidesFromScript(cscript);
    // reuse the reel's AI backgrounds across slides if present (round-robin), else gradient
    const specsWithBg = specs.map((s, i) => bgImagePaths.length ? { ...s, bgImage: bgImagePaths[i % bgImagePaths.length] } : s);
    const slideDir = path.join(dir, "carousel");
    await fs.mkdir(slideDir, { recursive: true });
    const slidePaths = await renderCarouselSlides(specsWithBg, slideDir);
    montagePath = await buildMontage(slidePaths, path.join(dir, "montage.png"));
    carousel = { slidePaths, caption: cscript.caption, hashtags: cscript.hashtags };
  } catch (e) {
    console.error(`carousel build failed (reel still proceeds): ${String(e).slice(0, 200)}`);
  }

(bgImagePaths, dir, fs, path, brief, vibe are already in scope in generateDaily.)

Change the Discord content line to mention both formats, and post the montage as a second image if present. Minimal version — update the postReel content string to note “🎬 reel → FB/YT/TikTok · 🖼️ carousel → IG” and, if montagePath, send it too (the existing postReel posts one file; add a follow-up await postText/image send for the montage, or include montage in the preview — use the existing discord helper that posts a file). Then add to the item:

    hook: script.hook, topic: brief.claim.topics[0] ?? "",
    carousel,

(If the Discord helper only posts one attachment, post the reel preview first, then a second message with the montage via the same file-posting helper. Keep it simple: two messages, one approval reaction set on the reel message as today.)

Run: npm run typecheck Expected: passes. (No new unit test here — this is orchestration; it’s exercised by the manual run in Task 12. The pieces it calls are unit-tested.)

git add src/auto/daily.ts
git commit -m "feat(oannes-engine): build carousel + montage in generate-daily (gated)"

Task 11: Publish both formats by route in poll-approvals

Files: - Modify: src/auto/approvals.ts

In src/auto/approvals.ts, where the approved item currently calls publishReelToAll, route by format. Replace the publish block with:

      await postText(token, channelId, `✅ Publishing: ${item.attribution} …`);
      const { platformsForFormat } = await import("../publish/routing.js");
      const results: { platform: string; ok: boolean; url?: string; error?: string; format?: "reel" | "carousel" }[] = [];

      // Reels go only to reel-platforms (FB/YT/TikTok by default).
      // publishReelToAll currently targets all 4; filter its results to reel-platforms.
      const reelPlatforms = new Set(platformsForFormat("reel"));
      const { videoUrl, results: reelResults } = await publishReelToAll(blotato, item, undefined, field);
      item.videoUrl = videoUrl;
      for (const r of reelResults) if (reelPlatforms.has(r.platform)) results.push({ ...r, format: "reel" });

      // Carousel goes to carousel-platforms (IG by default).
      if (item.carousel && platformsForFormat("carousel").includes("instagram")) {
        const { publishCarousel } = await import("../publish/publishCarousel.js");
        const c = await publishCarousel(blotato, item.carousel.slidePaths, item.carousel.caption, item.carousel.hashtags);
        results.push({ platform: "instagram", ok: c.ok, url: c.url, error: c.error, format: "carousel" });
      }

      item.results = results;
      item.status = "published";
      changed = true;
      const lines = results.map((r) => `${r.ok ? "✅" : "⚠️"} ${r.platform} (${r.format}): ${r.url ?? r.error ?? ""}`);
      await postText(token, channelId, [`Published **${item.attribution}**:`, ...lines].join("\n"));

Note: publishReelToAll still posts to all 4 platforms internally; the filter above keeps only reel-platform results so Instagram isn’t double-posted (reel→IG is dropped in favour of the carousel). To stop the wasted IG reel upload entirely, a follow-up can pass reel-platforms into publishReelToAll; for this plan, filtering the results is sufficient and keeps the change small. (Document this as a known minor inefficiency.)

Run: npm test Expected: passes (existing reconcile/approval tests still green; the publish path is orchestration over unit-tested pieces).

git add src/auto/approvals.ts
git commit -m "feat(oannes-engine): publish reel + carousel by platform route on approval"

Task 12: Manual end-to-end dry check (no scheduled-job disruption)

Files: none

Run:

/bin/bash -lc 'export PATH=/Users/joshua/.nvm/versions/node/v22.22.0/bin:/Users/joshua/.local/bin:/opt/homebrew/bin:"$PATH" && cd oannes-engine && npm run engine generate-daily'

Expected: reel builds AND carousel/slide-1..6.png + montage.png appear under renders/auto/<claimId>/; Discord shows the reel preview + the montage. Confirm slides read well and attribution is correct.

Tap ✅, then:

/bin/bash -lc 'export PATH=... && cd oannes-engine && npm run engine poll-approvals'

Expected: reel publishes to FB/YT/TikTok, carousel publishes to Instagram; the Discord summary lists each with its (format). Open the IG post — confirm it’s a real swipeable carousel.


Task 13: Metrics format dimension

Files: - Modify: src/metrics/store.ts, src/metrics/collect.ts, src/metrics/analyze.ts - Test: update tests/metrics-store.test.ts, tests/metrics-analyze.test.ts

In CREATE_TABLE_SQL add format text, (after art_key text,). Add format to MetricSnapshot (format: "reel" | "carousel"), to the buildInsert column list + values (place it right after art_key/hook/topic — keep the column order and $N count consistent), and to fetchLatestSnapshots row mapping (format: r.format as MetricSnapshot["format"]). Add an index: CREATE INDEX IF NOT EXISTS idx_opm_platform_format ON oannes_post_metrics (platform, format);. Update tests/metrics-store.test.ts‘s buildInsert test to include format: "carousel" in the snapshot and assert it appears in values.

The collector reads item.results[]; each result now has format. Pass it into the MetricSnapshot:

        format: (res.format as "reel" | "carousel") ?? "reel",

(default "reel" for older results without the tag). Update the CollectDeps/test fixtures’ results to carry format and assert it lands on the snapshot.

In aggregate, after computing per-platform weights, also compute a byFormat block per platform:

    file[p] = {
      vibe: meanByKey(scored, (r) => r.vibe),
      artKey: meanByKey(scored, (r) => r.artKey),
      topic: meanByKey(scored, (r) => r.topic),
      format: meanByKey(scored, (r) => r.format),  // mean stickiness per format
      sampleSize: scored.length,
    };

Add format: Record<string, number> to PlatformWeights and its zod schema. Update tests/metrics-analyze.test.ts fixtures to include format on rows and assert w.instagram.format.carousel is computed.

Run: npm test Expected: all pass.

git add src/metrics/ tests/metrics-store.test.ts tests/metrics-analyze.test.ts
git commit -m "feat(oannes-engine): format dimension (reel|carousel) in metrics"

Task 14: Final verification

Run: npm test Expected: all green.

Temporarily rename src/script/carouselscript.ts import target is irrelevant — instead simulate failure: confirm that if writeCarouselScript throws, generate-daily still queues+previews the reel (the try/catch in Task 10). Verify by reading the logs of Task 12’s run, or force an error path once. The reel must never be blocked by a carousel failure.


Self-Review

Spec coverage: - Routing format-per-platform (configurable) → Task 2 ✓ - One claim → both artifacts → Tasks 10 (build), 11 (publish) ✓ - ~6 slides 1080×1350, hook/reveal/attribution/CTA → Tasks 3 (slidesFromScript), 4 (component) ✓ - Discord montage preview → Tasks 6, 10 ✓ - Attribution required → Task 3 (zod .min(1, "attribution name required")) ✓ - Reuse scaffold (Carousel.tsx, renderCarousel, publishImagePost) → Tasks 4, 5, 7 ✓ - Blotato IG carousel feasibility first → Task 1 ✓ - Learning loop format dimension → Task 13 ✓ - Additive/gated (carousel failure never blocks reel) → Tasks 10 (try/catch), 14 ✓ - Build order matches spec’s 5 commits → Tasks grouped into COMMIT 1–5 ✓

Placeholder scan: Task 6’s montage has two filter approaches in the code (an xstack attempt then the concat+tile that’s actually used); the test asserts structure only, and Task 12 refines the filter against a real render — this is intentional (ffmpeg filter graphs are validated by running, not unit tests), not a placeholder. All other steps contain complete code.

Type consistency: Format/Platform in routing.ts (Platform re-used from stickiness.ts). CarouselScript/SlideSpec/slidesFromScript/writeCarouselScript consistent across Tasks 3, 5, 10. SlideProps (component) matches SlideSpec fields (kind/text/name/role/index/bgImage). publishCarousel/buildCarouselBody/CarouselResult consistent across Tasks 8, 11. PendingItem.carousel/results[].format consistent across Tasks 9, 11, 13. renderCarouselSlides consistent across Tasks 5, 10. uploadImage consistent across Tasks 7, 8.

Known minor inefficiency (documented in Task 11): publishReelToAll still uploads/posts the reel to all 4 platforms; we filter results to reel-platforms so IG isn’t double-counted, but the IG reel upload is wasted work. A follow-up can pass target platforms into publishReelToAll. Acceptable for v1.