Starter versions of the four bots that run my content: one finds topics, one fails bad cuts, one answers comments, one keeps score. You still approve every post.
Swipe or tap Next · checked on 2026-10-06
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.
Make the folder and start Claude in it
mkdir -p ~/content-bots
cd ~/content-bots
claudeThe setup prompt (copy all of it) · lines 1-5 of 18. Copy all copies the whole prompt; read it in full on the page.
Hi 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>.Hi 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.This is the manual way: every file and every setting. Use it by hand or keep it open to check what Claude is doing.
.env, never in a chat or a screen recording.The topic finder shows you what people want to watch. Make your version: your setup, your words, your list. Never their script, their list, their steps or their footage.
curl -fsSL https://claude.ai/install.sh | bash, then a new terminal and claude --version.python3 --version, ffmpeg -version).The folder
mkdir -p ~/content-bots/{bots,prompts,topics,frames}
cd ~/content-bots
touch .env && chmod 600 .env
echo '.env' >> .gitignoreMine 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.
Create 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 .env as YOUTUBE_API_KEY=... yourself.
5 to 10 phrases people in your lane search for. Specific beats broad: “roof leak repair” finds more you can use than “roofing”.
topics/keywords.txt
# one search per line
<keyword 1>
<keyword 2>
<keyword 3>bots/scout.py · lines 1-14 of 80. Copy all copies the whole file; read it in full on the page.
#!/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#!/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()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.
Test 1
python3 bots/scout.py
open topics/today.md 2>/dev/null || cat topics/today.mdPrompt
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.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.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.
prompts/critic.md · Part 1 of 2 · Copy copies all 16 lines
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>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.prompts/critic.md · Part 2 of 2 · Copy copies all 16 lines
If you're not sure, it passes.Test 2: run it on a finished video
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"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.
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.
Sign up, create a profile, and connect your Instagram (Business or Creator) and your Facebook Page. Instagram must be a professional account for DM automations.
Pick 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.
Same thing from the terminal. Your Zernio API key goes in .env as ZERNIO_API_KEY; the account and post ids come from Zernio. Drop the audience and followGate lines for a Facebook post.
Create an Instagram comment-to-DM automation · lines 1-9 of 17. Copy all copies the whole file; read it in full on the page.
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",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."}
}'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.
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).
bots/score.py · lines 1-15 of 71. Copy all copies the whole file; read it in full on the page.
#!/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#!/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()Test 4
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.
The crontab line
15 */3 * * * cd $HOME/content-bots && /usr/bin/python3 bots/score.py >> logs.txt 2>&11. What's working
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.2. Better keywords
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.3. A meaner critic
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.4. Check the comment bot
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.Claude and Upload-Post prices checked 2026-10-04. Prices change: check before you buy.
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.
HTTP Error 403The API isn't enabled for the key's project, the key is restricted to a different API, or you hit the daily quota. Check the Google Cloud console.
Raise the like-rate filter, or add -brandname to that keyword (search supports NOT with a minus sign).
Its list doesn't cover it. Add a line describing it in a way you could check with a ruler.
Check the automation is on the right post, the account is Instagram Business or Creator, and the caption actually asks for the keyword.
It needs request ids: runs/*/sent.json from the content engine, or add them to posted.txt one per line.
HTTP Error 429Too many requests at once. Wait five minutes; the next scheduled run picks up where this one stopped.
What each page backs up is listed at the end of the full guide.
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.
Want help setting this up? Receipts Group builds these systems.
Open the full guide as a page (every file in full, plus the table of contents).