DEV Community

Felipe Rohde
Felipe Rohde

Posted on

How I run my content pipeline with two AI bots, Cloudflare R2 and Postiz

I'm a dev trying to grow on X, LinkedIn and dev.to without spending my whole day posting. so I set up a small pipeline where AI bots do the repetitive part and I only show up twice a day, around 8h and 19h (Brazil time), to do the stuff that needs a human, like replying to people.

this is a build log of how it works today. it's not finished and some parts broke along the way, so I'll show those too.

the setup in one paragraph

there are two bots. a creative bot makes the images (carousels) and the Instagram posts in Portuguese. a manager bot owns everything else: it uploads the files to a public Cloudflare R2 bucket, plans the day, writes the English posts for X, LinkedIn and dev.to, and schedules them through Postiz. I only talk to the manager. the manager talks to the creative bot.

why two bots and not one

at first each bot did a bit of everything. the creative bot was also uploading to storage, also scheduling stuff, and I had no idea who did what. when something broke I didn't know where to look.

so I split it by responsibility:

  • creative bot: makes the art, drops it in a handoff folder, tells the manager it's ready, and schedules Instagram once it gets the public URLs back
  • manager bot: storage, calendar, scheduling for every English channel, and my daily checklists

the creative bot doesn't have write access to the bucket anymore. one writer, one source of truth.

R2 as the archive

I didn't want my disk (or the bots' disk) filling up with PNGs. so the rule is: if it's published, it lives in R2, and the local copy gets deleted.

the layout is boring on purpose:


<insta|x>/YYYY/MM/<YYYY-MM-DD>_<HHhMM>_<slug>/

    <slug>_01.png

    <slug>_02.png

    notes.txt

    caption.txt

Enter fullscreen mode Exit fullscreen mode

so a carousel scheduled for 12h today about a company called Conductor ends up in x/2026/10/2026-10-21_12h00_conductor/. there's also an x/latest.txt that just contains the prefix of the day's X carousel, so any routine can find today's files without listing the bucket.

notes.txt is the important one. it has the numbers for the case, where they came from, when they were checked, and which X handles were verified. the post writer is only allowed to use numbers that are in that file. that rule alone killed most of the "AI made up a stat" problems.

uploading with TypeScript

R2 speaks the S3 API, so the upload is just @aws-sdk/client-s3 pointed at the R2 endpoint:


// upload.ts

import { readdir, readFile } from "node:fs/promises";

import { join } from "node:path";

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";



const s3 = new S3Client({

  region: "auto",

  endpoint: process.env.R2_ENDPOINT!,

  credentials: {

    accessKeyId: process.env.R2_ACCESS_KEY_ID!,

    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,

  },

});



const BUCKET = process.env.R2_BUCKET!;

const PUBLIC_BASE = process.env.R2_PUBLIC_BASE!; // your r2.dev or custom domain



const contentType = (name: string) =>

  name.endsWith(".png") ? "image/png" : "text/plain; charset=utf-8";



export async function uploadFolder(localDir: string, prefix: string) {

  const names = (await readdir(localDir)).sort();

  const urls: string[] = [];



  for (const name of names) {

    const Body = await readFile(join(localDir, name));

    await s3.send(

      new PutObjectCommand({

        Bucket: BUCKET,

        Key: `${prefix}${name}`,

        Body,

        ContentType: contentType(name),

      }),

    );

    urls.push(`${PUBLIC_BASE}/${prefix}${name}`);

  }

  return urls;

}



export async function setLatest(prefix: string) {

  await s3.send(

    new PutObjectCommand({

      Bucket: BUCKET,

      Key: "x/latest.txt",

      Body: prefix,

      ContentType: "text/plain; charset=utf-8",

    }),

  );

}

Enter fullscreen mode Exit fullscreen mode

keys come from env vars, never from the code. and set the ContentType, or the public URL may download the PNG instead of showing it, which breaks things downstream.

checking before deleting anything

after upload the manager checks every public URL returns 200 before it deletes the local copy. Node 18+ has fetch built in, so no extra deps:


// verify.ts

import { rm } from "node:fs/promises";

import { uploadFolder, setLatest } from "./upload";



async function allOk(urls: string[]) {

  const res = await Promise.all(urls.map((u) => fetch(u, { method: "HEAD" })));

  const bad = urls.filter((_, i) => res[i].status !== 200);

  if (bad.length) console.error("not public yet:", bad);

  return bad.length === 0;

}



const local = "handoff/x/2026-10-21_12h00_conductor";

const prefix = "x/2026/10/2026-10-21_12h00_conductor/";



const urls = await uploadFolder(local, prefix);

if (await allOk(urls)) {

  await setLatest(prefix);

  await rm(local, { recursive: true });

}

Enter fullscreen mode Exit fullscreen mode

if any URL fails, nothing gets deleted and latest.txt doesn't move. boring, but it means I never lose a file.

a tiny Go check

I also wanted a dumb health check I can run from anywhere, so here's one in Go. it reads latest.txt over plain HTTP and checks that the first slide is actually there:


package main



import (

    "fmt"

    "io"

    "net/http"

    "os"

    "path"

    "strings"

)



func main() {

    base := os.Getenv("R2_PUBLIC_BASE")



    res, err := http.Get(base + "/x/latest.txt")

    if err != nil || res.StatusCode != 200 {

        fmt.Println("latest.txt not reachable")

        os.Exit(1)

    }

    defer res.Body.Close()



    b, _ := io.ReadAll(res.Body)

    prefix := strings.TrimSpace(string(b))

    slug := strings.SplitN(path.Base(prefix), "_", 3)[2]



    slide := fmt.Sprintf("%s/%s%s_01.png", base, prefix, slug)

    head, err := http.Head(slide)

    if err != nil || head.StatusCode != 200 {

        fmt.Println("missing:", slide)

        os.Exit(1)

    }

    fmt.Println("ok:", prefix)

}

Enter fullscreen mode Exit fullscreen mode

no SDK, no credentials. if the public side works, the bots can work.

Postiz for scheduling

Postiz is the piece that actually posts. it has an MCP server, so the manager bot can call it like a tool: upload media from a URL, get the schema for each platform, schedule a post with comments, list what's queued.

the nice part is that one call shape works for all channels. a post is a list where the first item is the post and the next items are comments. that's how sources end up in the first comment.

for media, I don't upload files from disk. the bot passes the public R2 URL to Postiz's upload-from-URL tool, gets back a hosted path, and attaches that. that's also why R2 has to be public.

the daily routines

everything runs on a schedule (Sao Paulo time):

  • 7h30: the morning plan. schedules the original X posts for the day, then sends me a checklist with replies to write and one quote tweet. I copy, paste, done
  • 10h: carousel day. reads x/latest.txt, reads notes.txt, uploads the PNGs to Postiz, writes the English text and schedules it on X for 12h
  • 18h30: the evening check. confirms the day's posts actually went out and sends me fresh replies plus a reminder to answer people who commented
  • Sunday 18h: drafts next week's posts so I can veto anything I don't like

the routines never ask me stuff during the day. if a step needs me, it goes into the 8h or 19h checklist. that was the only way this was going to survive my real life.

stuff that broke (lessons)

Postiz strips links on X. my first test post went out without the source link. now the source goes in the first comment as text, naming the creator and where to find it. no URL needed, and the person gets tagged, which is better anyway.

blank lines disappear. content goes in as HTML paragraphs, and two paragraphs in a row get glued together on X. to keep a visible blank line I need an empty

between them.

280 characters is real. I'm not on premium, so the writer counts characters before scheduling, and long ideas become a short post plus comments.

Postiz can't reply to other people. it can post and comment on my own posts, but not reply to someone else's tweet, and it can't delete something that already went live. so replies stay manual, which is why my checklists exist.

it wrote like a robot. the first posts were too clean. I had the bot read my real tweets and match them: lowercase, short blocks, no hashtags, no emojis.

no generic opinions. every original post needs a real number and a real source, usually a bigger account or founder.

what's next

LinkedIn and dev.to just joined the pipeline. LinkedIn gets the day's case rewritten in first person with the carousel images, dev.to gets one technical post a week like this one, in TypeScript or Go. all in English, only Instagram stays in Portuguese.

if you're running something similar, I'm curious what you automated and what you kept manual. drop it in the comments.

I post indie app breakdowns daily on X @felipe_rohde (https://x.com/felipe_rohde) and Instagram @feliperohde.dev (https://instagram.com/feliperohde.dev)

Top comments (0)