Four bots run my content. One finds what's breaking out in my lane. One watches every cut and fails it when it's not phone-ready. One answers comments with the guide. One pulls every post's numbers every three hours. And I still say yes to every post: none of them can publish anything.
This guide builds the starter version of each, with Claude Code doing the typing. You don't need to code. Each bot is one small script or one setting, and each one is useful on its own, so build them in any order.
The easy way: let Claude build them with you
Paste one prompt into Claude Code and it builds the bots one at a time, from the code boxes in this guide, testing each before the next. You do the parts that need your accounts (API keys, connecting Instagram) yourself.
mkdir -p ~/content-bots
cd ~/content-bots
claudeHi Claude. Please be my patient setup helper. I don't code: one step at a time, tell me exactly what to type or click, and wait for me.
We're building four content bots in this folder (~/content-bots), following this guide:
https://receiptsgroup.com/guides/four-content-bots
Its manual-way sections are the source of truth. Copy every file exactly from its code box; only fill in my details where it shows <angle brackets>.
Ask me which bots I want, then build them in this order, testing each one with the guide's test before moving on:
1. The topic finder (bots/scout.py, topics/keywords.txt). Help me pick 5-10 keywords for my lane.
2. The critic (prompts/critic.md and the frame command).
3. The comment answerer (Zernio, Instagram and Facebook). Walk me through the clicks; I connect the accounts myself.
4. The score keeper (bots/score.py) and its schedule.
Rules:
- Nothing you build may post, publish, DM or comment by itself, except the comment answerer I set up myself in Zernio.
- Never read, print or ask for API keys or tokens. Tell me which line of .env to fill in and let me do it.
- Before any command that changes something outside this folder (like crontab), explain it and wait for my yes.
Start by asking which bots I want.How the four fit together
This is the manual way: every file and every setting. Use it by hand or keep it open to check what Claude is doing.
- 1The topic finderbots/scout.py: YouTube search for your keywords, last 7 days, ranked against each channel's usual→ topics/today.md
- 2You make the videoYour camera, your avatar, your editor (my content engine guide)→ a finished cut
- 3The criticffmpeg pulls frames; Claude checks them against prompts/critic.md, read-only→ PASS, or FAIL with times
- 4Your yesYou watch it and post it→ a live post asking people to comment a keyword
- 5The comment answererZernio: comment the keyword, get the link by DM (Instagram checks they follow you)→ DMs and comment replies
- 6The score keeperbots/score.py every 3 hours: Upload-Post's per-post analytics→ scores.csv
Words you'll see
- API key: a long password that lets a script use one service. Lives in
.env, never in a chat or a screen recording. - Breaking out: a video getting far more views than its channel usually gets. A big channel's normal video isn't a trend; a small channel's 20x video is.
- Comment-to-DM: someone comments a word on your post, and a tool sends them a direct message with a link.
- cron: the clock built into Mac and Linux; one line says when to run what.
Before you start
- Claude Code (Claude Pro, Max, Team or Enterprise). Install:
curl -fsSL https://claude.ai/install.sh | bash, then a new terminal andclaude --version. - Python 3.9+ and FFmpeg (
python3 --version,ffmpeg -version). - A Google account for a free YouTube Data API key (bot 1).
- An Instagram Business or Creator account and a Facebook Page, connected in Zernio (bot 3). Check Zernio's pricing page for the plan that includes comment automations.
- Posts sent through Upload-Post for bot 4 (it reads their analytics). My content engine guide sets that up.
mkdir -p ~/content-bots/{bots,prompts,topics,frames}
cd ~/content-bots
touch .env && chmod 600 .env
echo '.env' >> .gitignoreBot 1: the topic finder
Mine scans YouTube and TikTok for short how-to and result videos in my lane that are beating their channel's usual by a mile, and throws out ads and brand posts. The starter version does the YouTube half with Google's official API.
Get a YouTube Data API key
Whereconsole.cloud.google.comCreate a project, open APIs & Services, then Library, search for YouTube Data API v3 and click Enable. Then Credentials, Create credentials, API key. Restrict the key to the YouTube Data API v3. Paste it into
.envasYOUTUBE_API_KEY=...yourself.PermissionsThe key only reads public YouTube data. It can't see or change your channel.What you'll seeGoogle Cloud console with the YouTube Data API v3 enabled and the key restricted. List your keywords
5 to 10 phrases people in your lane search for. Specific beats broad: “roof leak repair” finds more you can use than “roofing”.
# one search per line <keyword 1> <keyword 2> <keyword 3>Save the scout
#!/usr/bin/env python3 """Bot 1, the topic finder: short videos in your lane from the last 7 days, ranked by how far they beat their channel's average. Read-only: it searches YouTube and writes topics/today.md. Nothing is downloaded. python3 bots/scout.py Needs YOUTUBE_API_KEY in .env. Keywords come from topics/keywords.txt (one per line, # for comments). """ import datetime import json import os import urllib.parse import urllib.request from pathlib import Path HOME = Path(__file__).resolve().parent.parent API = "https://www.googleapis.com/youtube/v3" DAYS, MIN_VIEWS = 7, 1000 def env(name): f = HOME / ".env" for line in (f.read_text().splitlines() if f.exists() else []): line = line.removeprefix("export ").strip() if line.startswith(name + "="): return line.split("=", 1)[1].strip().strip("'\"") return os.environ.get(name, "") def get(path, **params): params["key"] = env("YOUTUBE_API_KEY") with urllib.request.urlopen(f"{API}/{path}?{urllib.parse.urlencode(params)}", timeout=30) as r: return json.load(r) def main(): now = datetime.datetime.now(datetime.timezone.utc) since = (now - datetime.timedelta(days=DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ") words = [w.strip() for w in (HOME / "topics" / "keywords.txt").read_text().splitlines() if w.strip() and not w.startswith("#")] found = {} for q in words: # short videos (under 4 minutes) from the last week, most viewed first r = get("search", part="snippet", q=q, type="video", order="viewCount", videoDuration="short", publishedAfter=since, maxResults=25, relevanceLanguage="en") for it in r.get("items", []): found.setdefault(it["id"]["videoId"], q) ids, videos = list(found), [] for i in range(0, len(ids), 50): videos += get("videos", part="snippet,statistics", id=",".join(ids[i:i + 50])).get("items", []) chans = list({v["snippet"]["channelId"] for v in videos}) avg = {} for i in range(0, len(chans), 50): # a channel's average views per video = its usual for c in get("channels", part="statistics", id=",".join(chans[i:i + 50])).get("items", []): s = c["statistics"] n = int(s.get("videoCount") or 0) avg[c["id"]] = int(s.get("viewCount") or 0) / n if n else 0 rows = [] for v in videos: s, sn = v["statistics"], v["snippet"] views, likes = int(s.get("viewCount") or 0), int(s.get("likeCount") or 0) usual = avg.get(sn["channelId"]) or 0 if views < MIN_VIEWS or not usual: continue if views >= 5000 and likes / views < 0.004: # huge views, almost no likes: usually a paid promotion continue posted = datetime.datetime.fromisoformat(sn["publishedAt"].replace("Z", "+00:00")) days = max(1, (now - posted).days) rows.append((round(views / usual, 1), views, days, sn["channelTitle"], sn["title"], v["id"], found[v["id"]])) rows.sort(reverse=True) out = [f"# Breaking out in my lane, {now:%Y-%m-%d}", "", "| vs their usual | views | days old | channel | video | keyword |", "|---|---|---|---|---|---|"] out += [f"| {r[0]}x | {r[1]:,} | {r[2]} | {r[3]} | [{r[4]}](https://www.youtube.com/watch?v={r[5]}) | {r[6]} |" for r in rows[:20]] (HOME / "topics").mkdir(exist_ok=True) (HOME / "topics" / "today.md").write_text("\n".join(out) + "\n") print("\n".join(out[:9])) if __name__ == "__main__": main()Run it and read the list
Good: a table of recent short videos with a “vs their usual” number; the top ones are the breakouts. Empty: your keywords are too narrow or too new; add broader ones.
python3 bots/scout.py open topics/today.md 2>/dev/null || cat topics/today.mdHave Claude pick the ones you can do better
Read topics/today.md. Pick the three topics I could make a better version of with my own setup and my own results. For each: the topic in one line, what I'd show on screen that's mine, and what I'd say differently. Don't reuse anyone's script, list, steps or wording. Read-only.
Bot 2: the critic
Mine measures every scene of a cut and fails it for text too small to read on a phone, a head too tiny in the frame, big empty areas, and the same layout twice in a row. Rude. Accurate. The starter version uses Claude's eyes on a frame every two seconds. It can't fix anything; it can only fail the cut and say where.
You are a critic with one job: say what's wrong with a vertical video, frame by frame. You never fix anything.
You get a folder of frames, one every 2 seconds (frame 1 = 0 s, frame 2 = 2 s, ...). Check each one:
1. Text: any word too small to read on a phone (under about 1/40 of the frame's height) fails.
2. Head: when a person is the subject, a face smaller than about 1/8 of the frame's height fails.
3. Empty: about a third of the frame or more with nothing in it fails.
4. Edges: text cut off, or in the top 7% or bottom 15% where the apps put their buttons, fails.
5. Repeats: two frames in a row that look the same for 6+ seconds fails.
6. Label: if it's made with an AI avatar, it's labelled as AI, on screen or with the platform's AI-content label.
Answer exactly:
PASS
or
FAIL
- <time>: <check number> <what's wrong> -> <the fix, one line>
If you're not sure, it passes.rm -rf frames/latest && mkdir -p frames/latest
ffmpeg -v error -i <path to your video>.mp4 -vf fps=1/2 frames/latest/f%03d.png
claude -p "Follow prompts/critic.md. Frames: frames/latest/" --permission-mode dontAsk --allowedTools "Read,Glob"Good: PASS, or FAIL lines with times you can check yourself. Fix them in your editor, or have Claude fix them if Claude made the edit, then run the critic again. Then watch the final one yourself, with sound. The critic is a filter, not a judge.
Bot 3: the comment answerer
Mine works like this: comment one word on Instagram, it checks you follow me, then DMs you the guide. On Facebook it DMs straight away. Everywhere else it replies with where to find it. The starter version is the Instagram and Facebook part, in Zernio. Only Instagram exposes whether someone follows you, so the follow check is Instagram-only.
Connect your accounts
WhereZernioSign up, create a profile, and connect your Instagram (Business or Creator) and your Facebook Page. Instagram must be a professional account for DM automations.
PermissionsMeta asks you to let Zernio read comments and send messages for the accounts you connect. Connect only the accounts you post from.Make one automation per post
WhereZernio, Comment automationsPick the post, then set: the keyword (
guide, matched as a whole word), the DM text with a button that opens your link, an optional public reply (“Sent it to your DMs”), and on Instagram, followers only, with a follow-first message for people who don't follow you yet. Zernio's docs give per-post automations priority over account-wide ones on the same keyword.Or make it with one API call
Same thing from the terminal. Your Zernio API key goes in
.envasZERNIO_API_KEY; the account and post ids come from Zernio. Drop theaudienceandfollowGatelines for a Facebook post.set -a; . ./.env; set +a curl -s -X POST https://zernio.com/api/v1/comment-automations \ -H "Authorization: Bearer $ZERNIO_API_KEY" -H "Content-Type: application/json" \ -d '{ "profileId": "<your Zernio profile id>", "accountId": "<your Instagram account id in Zernio>", "platformPostId": "<the post id>", "name": "GUIDE on <post name>", "trigger": "comment", "keywords": ["guide"], "matchMode": "word", "dmMessage": "Here you go! The full guide is below.", "buttons": [{"type": "url", "title": "Open the guide", "url": "<your guide link>"}], "commentReply": "Sent it to your DMs", "audience": {"followerStatus": "follower", "whenUnknown": "verify"}, "followGate": {"message": "Follow me first, then tap the button and it is yours.", "buttonLabel": "I am following", "notFollowingMessage": "Not following yet. Follow, then tap again."} }'Test it with a second account
From another Instagram account, comment “guide” on the post. You should get the follow-first message, then the DM after following. Then ask your caption to say it: “Comment GUIDE and I'll send you...”. A keyword nobody's told about gets no comments.
Bot 4: the score keeper
Mine pulls every post's numbers every three hours and compares posts at the same age, because a five-day-old post always beats a one-day-old one on totals. The starter version appends every post's latest numbers to a spreadsheet file every three hours. It needs posts sent through Upload-Post (the upload answer includes a request_id per job; this bot reads them).
#!/usr/bin/env python3
"""Bot 4, the score keeper: every posted video's numbers, per platform, appended to scores.csv. Read-only on your
accounts: it only reads analytics from Upload-Post.
python3 bots/score.py
Needs UPLOAD_POST_API_KEY in .env. It finds your posts' request ids in the upload responses your posting step saved
(runs/*/sent.json from the content engine guide) or in posted.txt (one request id per line).
"""
import csv
import datetime
import json
import os
import re
import time
import urllib.request
from pathlib import Path
HOME = Path(__file__).resolve().parent.parent
API = "https://api.upload-post.com/api/uploadposts/post-analytics/"
FIELDS = ["checked_at", "request_id", "platform", "views", "likes", "comments", "shares", "saves", "post_url"]
def env(name):
f = HOME / ".env"
for line in (f.read_text().splitlines() if f.exists() else []):
line = line.removeprefix("export ").strip()
if line.startswith(name + "="):
return line.split("=", 1)[1].strip().strip("'\"")
return os.environ.get(name, "")
def request_ids():
ids = set()
for f in HOME.glob("runs/*/sent.json"):
ids |= set(re.findall(r'request_id\W+([0-9a-f]{16,})', f.read_text()))
p = HOME / "posted.txt"
if p.exists():
ids |= {l.strip() for l in p.read_text().splitlines() if l.strip()}
return sorted(ids)
def main():
key, now = env("UPLOAD_POST_API_KEY"), datetime.datetime.now().isoformat(timespec="minutes")
path = HOME / "scores.csv"
new = not path.exists()
with path.open("a", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
if new:
w.writeheader()
for rid in request_ids():
req = urllib.request.Request(API + rid, headers={"Authorization": f"Apikey {key}"})
try:
with urllib.request.urlopen(req, timeout=90) as r:
data = json.load(r)
except Exception as e: # one bad post never stops the rest
print(f"{rid}: {e}")
continue
for platform, v in (data.get("platforms") or {}).items():
m = v.get("post_metrics") or {}
if not v.get("success") or not isinstance(m, dict):
continue
w.writerow({"checked_at": now, "request_id": rid, "platform": platform, "views": m.get("views"),
"likes": m.get("likes"), "comments": m.get("comments"), "shares": m.get("shares"),
"saves": m.get("saves"), "post_url": v.get("post_url")})
time.sleep(4) # stay well under the live analytics limit (100 requests per 5 minutes)
print(f"scores.csv updated at {now}")
if __name__ == "__main__":
main()python3 bots/score.py
tail -5 scores.csvThen put it on a clock. crontab -e opens your schedule; add this line (fix the folder from which python3 if yours is elsewhere), save, close. 15 */3 * * * means quarter past every third hour.
15 */3 * * * cd $HOME/content-bots && /usr/bin/python3 bots/score.py >> logs.txt 2>&1Real use: prompts for real jobs
Read scores.csv. For each post, take its latest row per platform. Which three posts did best on views and which on saves or shares? What do they have in common (topic, hook, length)? Read-only; say what you'd test next week.Read topics/keywords.txt and the last week of topics/today.md. Which keywords never find anything over 3x? Suggest replacements in my lane, show me before and after, wait for my yes.Here's what bugged me in my last video: <your notes>. Turn each into one checkable line in prompts/critic.md. Show me the change before saving.Using the Zernio API with my key from .env (never print it), list my comment automations with their triggered and sent counts. Read-only. Flag any post asking for GUIDE that has no automation.What it costs
| Bot | Tool | Cost |
|---|---|---|
| All four | Claude Pro (Claude Code) | $20 a month at list price |
| Topic finder | YouTube Data API | Free within Google's daily quota |
| Critic | FFmpeg + Claude | Free (uses your Claude plan) |
| Comment answerer | Zernio | Check Zernio's pricing page |
| Score keeper | Upload-Post | On the plan you post with (Basic $24 a month at list price) |
Every Claude run counts toward your plan's usage limits; the critic reads images, which is heavier than text, so one frame every two seconds is plenty for a short.
What my version adds (and this guide doesn't)
- Topic finder: YouTube and TikTok, each video against its channel's recent median (not the lifetime average), promoted videos and brand channels thrown out, and a score for how remakeable it is.
- Critic: measured, not eyeballed: text heights and faces from Apple's Vision framework, empty-space maps, layout repeats; it writes a contact sheet with every scene bordered green, amber or red.
- Comment answerer: Zernio on Instagram and Facebook, plus my own replies on TikTok, YouTube and Threads pointing to the guide.
- Score keeper: snapshots every 3 hours into a database, so posts compare at about 24 hours old, grouped by format, opening and length in a dashboard.
When it breaks
scout.py: HTTP Error 403
scout.py: the top results are brand ads
-brandname to that keyword (search supports NOT with a minus sign).The critic passes something you can see is wrong
Nobody gets a DM
score.py finds no posts
runs/*/sent.json from the content engine, or add them to posted.txt one per line.score.py: HTTP Error 429
That's the four
A bot that finds the topic, one that fails the bad cut, one that answers the comments, one that keeps score. And you, saying yes to every post. Build the one that saves you the most annoyance first.
Checked on Oct 6, 2026 against
Every claim about a third-party tool in this guide (plans, prices, menu paths, commands, limits) was checked against these official pages on Oct 6, 2026. These screens change often: if something looks different, trust the page over this guide.
- YouTube Data API: search.list
q,type,order=viewCount,videoDuration=short(under 4 minutes),publishedAfter,maxResultsup to 50; NOT with a minus sign. - YouTube Data API: videos.list
statistics: views and likes per video. - YouTube Data API: channels.list
statistics: a channel's total views and video count. - Zernio API: Create comment-to-DM automation
POST /v1/comment-automations; Instagram and Facebook; keywords, matchMode, dmMessage, buttons, commentReply, followGate; the follower audience is Instagram only; per-post automations take priority. - Upload-Post API (OpenAPI spec)
Authorization: Apikey ...; per-post analytics; the live analytics limit of 100 requests per 5 minutes. - Claude Code: Run Claude Code programmatically
claude -pwith--allowedToolsfor the read-only critic. - Claude: Plans and pricingPro at $20 a month billed monthly (checked 2026-10-04).
- Upload-Post: Pricing and limitsBasic at $24 a month billed monthly (checked 2026-10-04).
- crontab(5) manualThe five time fields and
*/3steps.
