
Tiktok App Marketing
- 1 installs
- Updated May 27, 2026
- upload-post/upload-post-skills
Automates TikTok and Instagram slideshow marketing: competitor research, AI image generation, text overlays, multi-platform posting via Upload-Post, analytics, and a hook/CTA feedback loop.
About
Runs a full slideshow marketing pipeline (generate, overlay, post, track, iterate) posting to TikTok, Instagram, and more in one Upload-Post call and adjusting hooks and CTAs from views-vs-conversion data. A developer uses it to set up and optimize automated social marketing for an app or product.
- Multi-platform posting plus analytics through Upload-Post
- Feedback loop tuning hooks/CTAs, optional RevenueCat conversion tracking
Tiktok App Marketing by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,710 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/upload-post/upload-post-skills --skill tiktok-app-marketingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 27, 2026 |
| Repository | upload-post/upload-post-skills ↗ |
What it does
Automates TikTok and Instagram slideshow marketing: competitor research, AI image generation, text overlays, multi-platform posting via Upload-Post, analytics, and a hook/CTA feedback loop.
Files
TikTok App Marketing
Automate your entire TikTok slideshow marketing pipeline: generate → overlay → post → track → iterate.
Proven results: 7 million views on the viral X article, 1M+ TikTok views, $670/month MRR — all from an AI agent running on an old gaming PC.
Prerequisites
This skill does NOT bundle any dependencies. Your AI agent will need to research and install the following based on your setup. Tell your agent what you're working with and it will figure out the rest.
Required
- Node.js (v18+) — all scripts run on Node. Your agent should verify this is installed and install it if not.
- node-canvas (
npm install canvas) — used for adding text overlays to slide images. This is a native module that may need build tools (Python, make, C++ compiler) on some systems. Your agent should research the install requirements for your OS. - Upload-Post — this is the backbone of the whole system. Upload-Post handles posting to TikTok, Instagram, and 10+ other platforms simultaneously with a single API call. It also provides analytics (followers, impressions, reach) and upload history (per-post tracking) that power the daily feedback loop. Without Upload-Post, the agent can't post or track what's working — and the feedback loop is what makes this skill actually grow your account instead of just posting blindly. Sign up at upload-post.com.
Image Generation (pick one)
You choose what generates your images. Your agent should research the API docs for whichever you pick:
- OpenAI —
gpt-image-1.5(ALWAYS 1.5, never 1). Needs an OpenAI API key. Best for realistic photo-style images. This is what Larry uses and what we strongly recommend. - Stability AI — Stable Diffusion XL and newer. Needs a Stability AI API key. Good for stylized/artistic images.
- Replicate — run any open-source model (Flux, SDXL, etc.). Needs a Replicate API token. Most flexible.
- Local — bring your own images. No API needed. Place images in the output directory and the script skips generation.
Conversion Tracking (optional but recommended for mobile apps)
- RevenueCat — this is what completes the intelligence loop. Upload-Post tells you which posts get impressions. RevenueCat tells you which posts drive paying users. Combined, the agent can distinguish between a viral post that makes no money and a modest post that actually converts — and optimize accordingly. Install the RevenueCat skill from ClaWHub (
clawhub install revenuecat) for full API access to subscribers, MRR, trials, churn, and revenue. There's also a RevenueCat MCP for programmatic control over products and offerings from your agent/IDE.
Cross-Posting (built-in with Upload-Post)
Upload-Post supports posting to TikTok, Instagram, YouTube, LinkedIn, Facebook, X (Twitter), Threads, Pinterest, Reddit, and Bluesky — all in a single API call. Your agent should research which platforms fit your audience and connect them in your Upload-Post profile. Same content, different algorithms, more reach.
First Run — Onboarding
When this skill is first loaded, IMMEDIATELY start a conversation with the user. Don't dump a checklist — talk to them like a human marketing partner would. The flow below is a guide, not a script. Be natural. Ask one or two things at a time. React to what they say. Build on their answers.
Important: Use scripts/onboarding.js --validate at the end to confirm the config is complete.
Phase 0: TikTok Account Warmup (CRITICAL — Don't Skip This)
Before anything else, check if the user already has a TikTok account with posting history. If they're creating a fresh account, they MUST warm it up first or TikTok will treat them like a bot and throttle their reach from day one.
Explain this naturally:
"Quick question before we dive in — do you already have a TikTok account you've been using, or are we starting fresh? If it's new, we need to warm it up first. TikTok's algorithm watches how new accounts behave, and if you go straight from creating an account to posting AI slideshows, it flags you as a bot and kills your reach."
If the account is new or barely used, walk them through this:
The goal is to use TikTok like a normal person for 7-14 days before posting anything. Spend 30-60 minutes a day on the app:
- Scroll the For You page naturally. Watch some videos all the way through. Skip others halfway. Don't watch every single one to the end — that's not how real people scroll.
- Like sparingly. Maybe 1 in 10 videos. Don't like everything — that's bot behaviour. Only like things you'd genuinely engage with in your niche.
- Follow accounts in your niche. If they're promoting a fitness app, follow fitness creators. Room design? Interior design accounts. This trains the algorithm to understand what the account is about.
- Watch niche content intentionally. This is the most important part. TikTok learns what you engage with and starts showing you more of it. You want the For You page dominated by content similar to what you'll be posting.
- Leave a few genuine comments. Not spam. Real reactions. A few per session.
- Maybe post 1-2 casual videos. Nothing promotional. Just normal content that shows TikTok there's a real person behind the account.
The signal to look for: When they open TikTok and almost every video on their For You page is in their niche, the account is warmed up. The algorithm understands them. NOW they can start posting.
Tell the user: "I know two weeks feels like wasted time, but accounts that skip warmup consistently get 80-90% less reach on their first posts. Do the warmup. It's the difference between your first post getting 200 views and 20,000."
If the account is already active and established, skip this entirely and move to Phase 1.
Phase 1: Get to Know Their App (Conversational)
Start casual. Something like:
"Hey! Let's get your TikTok marketing set up. First — tell me about your app. What's it called, what does it do?"
Then FOLLOW UP based on what they say. Don't ask all 9 questions at once. Pull the thread:
- They mention what it does → ask who it's for ("Who's your ideal user?")
- They describe the audience → ask about the pain point ("What's the main problem it solves for them?")
- They explain the problem → ask what makes them different ("What makes yours stand out vs alternatives?")
- Get the App Store / website link naturally ("Can you drop me the link?")
- Determine category (home/beauty/fitness/productivity/food/other) — often inferable
Don't ask for "brand guidelines" robotically. Instead: "Do you have any existing content or a vibe you're going for? Or are we starting fresh?"
Then ask about their app and monetization:
"Is this a mobile app? And do you use RevenueCat (or any subscription/in-app purchase system) to handle payments?"
This is critical because it determines whether we can close the full feedback loop. If they have a mobile app with RevenueCat:
- Tell them about the RevenueCat skill on ClawHub (
clawhub install revenuecat). It gives full API access to subscribers, MRR, trials, churn, revenue, and transactions. Don't auto-install — just let them know it exists and what it unlocks, and they can install it if they want. - Explain why it matters: Without RevenueCat data, the skill can only optimize for views (vanity metrics). With it, the skill optimizes for actual paying users. The difference is massive. A post with 200K views and zero conversions is worthless. A post with 5K views and 10 paid subscribers is gold. You can only tell the difference with RevenueCat connected.
If they don't use RevenueCat but have another subscription system, note it and work with what's available. If it's not a mobile app (e.g. physical product, SaaS, service), skip RevenueCat but still track whatever conversion metric they have (website signups, purchases, leads).
Store everything in tiktok-marketing/app-profile.json.
Phase 2: Competitor Research (Requires Browser Permission)
Before building any content strategy, research what competitors are doing on TikTok. This is critical — you need to know the landscape.
Ask the user:
"Before we start creating content, I want to research what your competitors are doing on TikTok — what's getting views in your niche, what hooks they're using, what's working and what's not. Can I use the browser to look around TikTok and the App Store?"
Wait for permission. Then:
1. Search TikTok for the app's niche (e.g. "interior design app", "lip filler filter", "fitness transformation app") 2. Find 3-5 competitor accounts posting similar content 3. Analyze their top-performing content:
- What hooks are they using?
- What slide format? (before/after, listicle, POV, tutorial)
- How many views on their best vs average posts?
- What's their posting frequency?
- What CTAs are they using?
- What music/sounds are trending in the niche?
4. Check the App Store for the app's category — look at competitor apps, their screenshots, descriptions, ratings 5. Compile findings into tiktok-marketing/competitor-research.json:
{
"researchDate": "2026-02-16",
"competitors": [
{
"name": "CompetitorApp",
"tiktokHandle": "@competitor",
"followers": 50000,
"topHooks": ["hook 1", "hook 2"],
"avgViews": 15000,
"bestVideo": { "views": 500000, "hook": "..." },
"format": "before-after slideshows",
"postingFrequency": "daily",
"cta": "link in bio",
"notes": "Strong at X, weak at Y"
}
],
"nicheInsights": {
"trendingSounds": [],
"commonFormats": [],
"gapOpportunities": "What competitors AREN'T doing that we could",
"avoidPatterns": "What's clearly not working"
}
}6. Share findings with the user conversationally:
"So I looked at what's out there. [Competitor A] is doing well with [format] — their best post got [X] views using [hook type]. But I noticed nobody's really doing [gap]. That's our angle."
This research directly informs hook generation and content strategy. Reference it when creating posts.
Phase 3: Content Format & Image Generation
First, ask about format:
"Do you want to do slideshows (photo carousels) or video? Slideshows are what Larry uses and what this skill is built around — TikTok's data shows they get 2.9x more comments and 2.6x more shares than video, and they're much easier for AI to generate consistently. That said, if you want to try video, the skill supports it but it hasn't been battle-tested like slideshows have. Your call."
Store their choice as format: "slideshow" or format: "video" in config. If they pick video, note that the text overlay, 6-slide structure, and prompt templates are designed for slideshows. Video will require more experimentation and the agent should be upfront about that.
For slideshows (recommended):
Ask naturally:
"For the slideshows, we need images. I'd strongly recommend OpenAI's gpt-image-1.5 — it's what Larry uses and it produces images that genuinely look like someone took them on their phone. It's the difference between 'obviously AI' and 'wait, is that real?' You can also use Stability AI, Replicate, or bring your own images if you prefer."
⚠️ If they pick OpenAI, make sure the model is set to `gpt-image-1.5` — NEVER `gpt-image-1`. The difference in quality is massive. gpt-image-1 produces noticeably AI-looking images that people scroll past. gpt-image-1.5 produces photorealistic results that stop the scroll. This one setting can be the difference between 1K and 100K views.
If they're unsure, always recommend gpt-image-1.5. It's the proven choice.
Store in config as imageGen with provider, apiKey, and model.
If they pick OpenAI, mention the Batch API:
"One thing worth knowing — OpenAI has a Batch API that's 50% cheaper than real-time generation. Instead of generating slides on the spot, you submit them as a batch job and get results within 24 hours (usually much faster). It's perfect for pre-generating tomorrow's slides overnight. Same quality, half the cost. Want me to set that up?"
If they're interested, store "useBatchAPI": true in imageGen config. The generate script supports both modes — real-time for quick iterations, batch for scheduled daily content.
Then — and this is critical — work through the image style with them. Don't just use a generic prompt. Bad images = nobody watches. Ask these naturally, one or two at a time:
"Now let's figure out what these images should actually look like. Do you want them to look like real photos someone took on their phone, or more like polished graphics or illustrations?"
Then based on their answer, dig deeper:
- What's the subject? "What are we actually showing? Rooms? Faces? Products? Before/after comparisons?"
- What vibe? "Cozy and warm? Clean and minimal? Luxurious? Think about what your audience relates to or aspires to."
- Consistency: "Should all 6 slides look like the same place or person? If yes — I need to lock down specific details so each slide doesn't look totally different."
- Must-have elements? "Anything that HAS to be in every image? A specific product? Certain furniture? A pet?"
Build the base prompt WITH them. A good base prompt looks like:
iPhone photo of a [specific room/scene], [specific style], [specific details].
Realistic lighting, natural colors, taken on iPhone 15 Pro.
No text, no watermarks, no logos.
[Consistency anchors: "same window on left wall", "same grey sofa", "wooden coffee table in center"]Save the agreed prompt style to config as `imageGen.basePrompt` so every future post uses it.
Key prompt rules (explain these as they come up, don't lecture):
- "iPhone photo" + "realistic lighting" = looks real, not AI-generated
- Lock architecture/layout in EVERY slide prompt or each slide looks like a different place
- Include everyday objects (mugs, remotes, magazines) for lived-in feel
- For before/after: "before" = modern but tired, NOT ancient
- Portrait orientation (1024x1536) always — this is TikTok
- Extremely specific > vague ("small galley kitchen with white cabinets and a window above the sink" > "a kitchen")
NEVER use generic prompts like "a nice living room" or "a beautiful face" — they produce generic images that get scrolled past.
Phase 4: Upload-Post Setup (ESSENTIAL — Powers Multi-Platform Posting + Analytics)
Upload-Post is the engine that makes multi-platform posting and analytics tracking work. With a single API call, your content goes to TikTok, Instagram, and any other connected platform simultaneously. It provides:
- Multi-platform posting — TikTok + Instagram (+ YouTube, LinkedIn, Facebook, X, Threads, Pinterest, Reddit, Bluesky) in one API call
- Upload history — per-post tracking with request_ids, post URLs, success/failure status
- Platform analytics — followers, impressions, reach, profile views, timeseries data
- Automatic tracking — no manual video-ID linking needed (tracks by request_id)
Frame it naturally to the user:
"So here's the key piece — we need Upload-Post to handle posting and analytics. It's what lets me post to TikTok and Instagram simultaneously with a single API call, and track every post's performance. One upload, multiple platforms, automatic tracking."
Walk them through connecting step by step:
1. Sign up at [upload-post.com](https://upload-post.com) — create an account 2. Connect TikTok — this is the main one. Go to your profile → Connect TikTok → Authorize 3. Connect Instagram — same process. This doubles your reach for free. 4. Create a Profile — this is the profile name used in API calls (e.g., "mybrand"). Note this down. 5. Get the API key — Dashboard → API Keys → Generate. This is how the agent talks to Upload-Post programmatically. 6. (Optional) Connect YouTube, LinkedIn, Threads, etc. for even more reach — same content, different algorithms.
Don't move on until Upload-Post is connected and the API key works. Test it by hitting the analytics endpoint. If it returns data, you're good.
Phase 5: Conversion Tracking (THE Intelligence Loop)
If they have a mobile app with RevenueCat (you should already know this from Phase 1), this is where the skill goes from "content automation" to "intelligent marketing system." This is the most important integration in the entire skill. Don't treat it as optional.
Explain WHY it matters:
"So right now with Upload-Post, I can track which posts get impressions and reach, and whether they were successfully delivered to each platform. That's the top of the funnel. But impressions alone don't pay the bills — we need to know which posts actually drive paying subscribers."
>
"This is where RevenueCat comes in. It tracks your subscribers, trials, MRR, churn — the actual revenue. When I combine platform analytics from Upload-Post with conversion data from RevenueCat, I can make genuinely intelligent decisions:"
>
"If a post gets 50K impressions but zero conversions, I know the hook is great but the CTA or app messaging needs work. If a post gets 2K impressions but 5 paid subscribers, I know the content converts amazingly — we just need more eyeballs on it, so we fix the hook."
>
"Without RevenueCat, I'm optimizing for vanity metrics. With it, I'm optimizing for revenue."
Walk them through setup step by step:
1. Install the RevenueCat skill from ClaWHub:
clawhub install revenuecatThis installs the revenuecat skill (v1.0.2+) which gives full API access to your RevenueCat project — metrics overview, customers, subscriptions, offerings, entitlements, transactions, and more. It includes reference docs for every API endpoint and a helper script (scripts/rc-api.sh) for direct API calls.
2. Get your V2 secret API key from the RevenueCat dashboard:
- Go to your RC project → Settings → API Keys
- Generate a V2 secret key (starts with
sk_) - ⚠️ This is a SECRET key — don't commit it to public repos
3. Set the environment variable:
export RC_API_KEY=sk_your_key_here4. Verify it works: Run ./skills/revenuecat/scripts/rc-api.sh /projects — should return your project details.
5. Optional: RevenueCat MCP — for programmatic control over products, offerings, and entitlements from your agent or IDE. Ask your agent to research setting this up.
What RevenueCat gives the daily report:
GET /projects/{id}/metrics/overview→ MRR, active subscribers, active trials, churn rateGET /projects/{id}/transactions→ individual purchases with timestamps (for conversion attribution)- The daily cron cross-references transaction timestamps with post publish times (24-72h window) to identify which posts drove which conversions
The intelligence this unlocks:
- "This hook got 50K views but zero conversions" → hook is great, CTA needs work
- "This hook got 5K views but 3 paid subscribers" → content converts amazingly, fix the hook for more reach
- "Conversions are consistently poor across all posts" → might be an app issue (onboarding, paywall, pricing) not a content issue — the skill flags this for investigation
Without RevenueCat: The loop still works on Upload-Post analytics (impressions/reach/upload status). You can optimize for engagement. But you're flying blind on revenue. You'll know which posts get impressions but you won't know which posts make money.
With RevenueCat: You optimize for actual paying users. You can tell the difference between a viral post that makes nothing and a quiet post that drives $50 in subscriptions. This is the entire point of the feedback loop. Every decision the daily report makes is better with RevenueCat data.
If they don't use RevenueCat or don't have subscriptions, the skill still works but the feedback loop is limited to impression-based optimization only.
Phase 6: Content Strategy (Built from Research)
Using the competitor research AND the app profile, build an initial content strategy:
"Based on what I found and what your app does, here's my plan for the first week..."
Present: 1. 3-5 hook ideas tailored to their niche + competitor gaps 2. Posting schedule recommendation (default: 7:30am, 4:30pm, 9pm — their timezone) 3. Which hook categories to test first (reference what worked for competitors) 4. Cross-posting plan (which platforms, same or adapted content)
Save the strategy to tiktok-marketing/strategy.json.
Phase 7: Set Up the Daily Analytics Cron
This is what makes the whole system self-improving. Set up a daily cron job that:
1. Pulls platform analytics from Upload-Post (followers, impressions, reach trends) 2. Pulls upload history to check post success/failure across platforms 3. Pulls conversion data from RevenueCat (if connected) 4. Cross-references impressions with conversions to diagnose what's working 5. Generates a report with specific recommendations 6. Suggests new hooks based on performance patterns
Explain to the user:
"I'm going to set up a daily check that runs every morning. It looks at how your posts from the last 3 days performed — platform analytics, upload status across TikTok and Instagram, and if you've got RevenueCat connected, actual conversions. Then it tells you exactly what's working and what to change."
>
"Posts typically peak at 24-48 hours, and conversions take up to 72 hours to attribute, so checking a 3-day window gives us the full picture."
Set up the cron:
Use the agent's cron system to schedule a daily analytics job. Run it every morning before the first post of the day (e.g. 7:00 AM in the user's timezone) so the report informs that day's content:
Schedule: daily at 07:00 (user's timezone)
Task: Run scripts/daily-report.js --config tiktok-marketing/config.json --days 3
Output: tiktok-marketing/reports/YYYY-MM-DD.md + message to user with summaryThe daily report uses the diagnostic framework:
- High views + High conversions → Scale it — more of the same, test posting times
- High views + Low conversions → Hook works, CTA is broken — test new CTAs on slide 6, check app landing page
- Low views + High conversions → Content converts but nobody sees it — test radically different hooks, keep the CTA
- Low views + Low conversions → Full reset — new format, new audience angle, new hook categories
This is the intelligence layer. Without it, you're just posting and hoping. With it, every day's content is informed by data.
Phase 8: Save Config & First Post
Store everything in tiktok-marketing/config.json (this is the source of truth for the entire pipeline):
{
"app": {
"name": "AppName",
"description": "Detailed description",
"audience": "Target demographic",
"problem": "Pain point it solves",
"differentiator": "What makes it unique",
"appStoreUrl": "https://...",
"category": "home|beauty|fitness|productivity|food|other",
"isMobileApp": true
},
"imageGen": {
"provider": "openai",
"apiKey": "sk-...",
"model": "gpt-image-1.5"
},
"uploadPost": {
"apiKey": "your-upload-post-api-key",
"profile": "your_profile_name",
"platforms": ["tiktok", "instagram"]
},
"revenuecat": {
"enabled": false,
"v2SecretKey": "sk_...",
"projectId": "proj..."
},
"posting": {
"schedule": ["07:30", "16:30", "21:00"],
"crossPost": ["youtube", "threads"]
},
"competitors": "tiktok-marketing/competitor-research.json",
"strategy": "tiktok-marketing/strategy.json"
}Then generate the first test slideshow — but set expectations:
"Let's create our first slideshow. This is a TEST — we're dialing in the image style, not posting yet. I'll generate 6 slides and we'll look at them together. If the images look off, we tweak the prompts and try again. The goal is to get the look nailed down BEFORE we start posting."
⚠️ THE REFINEMENT PROCESS IS PART OF THE SKILL:
Getting the images right takes iteration. This is normal and expected. Walk the user through it:
1. Generate a test set of 6 images using the prompts you built together 2. Show them the results and ask: "How do these look? Too polished? Too dark? Wrong vibe? Wrong furniture?" 3. Tweak based on feedback — adjust the base prompt, regenerate 4. Repeat until they're happy — this might take 2-5 rounds, that's fine 5. Lock the prompt style once it looks right — save to config
Things to watch for and ask about:
- "Are these realistic enough or do they look AI-generated?"
- "Is the lighting right? Too bright? Too moody?"
- "Does this match what your users would actually relate to?"
- "Are the everyday details right? (furniture style, objects, layout)"
You do NOT have to post anything you don't like. The first few generations are purely for refining the prompt. Only start posting once the images consistently look good. The agent learns from each round — what works, what doesn't, what to emphasise in the prompt.
Once the style is locked in, THEN use the hook strategy from competitor research and their category (see references/slide-structure.md) and start the posting schedule.
---
Core Workflow
1. Generate Slideshow Images
Use scripts/generate-slides.js:
node scripts/generate-slides.js --config tiktok-marketing/config.json --output tiktok-marketing/posts/YYYY-MM-DD-HHmm/ --prompts prompts.jsonThe script auto-routes to the correct provider based on config.imageGen.provider. Supports OpenAI, Stability AI, Replicate, or local images.
⚠️ Timeout warning: Generating 6 images takes 3-9 minutes total (30-90 seconds each for gpt-image-1.5). Set your exec timeout to at least 600 seconds (10 minutes). If you get spawnSync ETIMEDOUT, the exec timeout is too short. The script supports resume — if it fails partway, re-run it and completed slides will be skipped.
Critical image rules (all providers):
- ALWAYS portrait aspect ratio (1024x1536 or 9:16 equivalent) — fills TikTok screen
- Include "iPhone photo" and "realistic lighting" in prompts (for AI providers)
- ALL 6 slides share the EXACT same base description (only style/feature changes)
- Lock key elements across all slides (architecture, face shape, camera angle)
- See references/slide-structure.md for the 6-slide formula
2. Add Text Overlays
This step uses node-canvas to render text directly onto your slide images. This is how Larry produces slides that have hit 1M+ views on TikTok — the text sizing, positioning, and styling are dialled in from hundreds of posts.
Setting Up node-canvas
Before you can add text overlays, your human needs to install node-canvas. Prompt them:
"To add text overlays to the slides, I need a library called node-canvas. It renders text directly onto images with full control over sizing, positioning, and styling — this is what Larry uses for his viral TikTok slides.
>
Can you run this in your terminal?"
>
```bash
npm install canvas
```
>
"If that fails, it's because node-canvas needs some system libraries. Here's what to install first:"
>
macOS:
```bash
brew install pkg-config cairo pango libpng jpeg giflib librsvg
npm install canvas
```
>
Ubuntu/Debian:
```bash
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
npm install canvas
```
>
Windows:
```bash
# node-canvas auto-downloads prebuilt binaries on Windows
npm install canvas
```
>
"Once installed, I can handle everything else — generating the overlays, sizing the text, positioning it perfectly. You won't need to touch this again."
Don't skip this step. Without node-canvas, the text overlays won't work. If installation fails, help them troubleshoot — it's usually a missing system library. Once it's installed once, it stays.
How Larry's Text Overlay Process Works
1. Load the raw slide image into a node-canvas 2. Configure text settings based on the text length for that specific slide 3. Draw the text with white fill and thick black outline 4. Review the output — check sizing, positioning, readability 5. Adjust and re-render if anything looks off 6. Save the final image once it looks right
Exact code Larry uses:
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs');
async function addOverlay(imagePath, text, outputPath) {
const img = await loadImage(imagePath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ─── Adjust font size based on text length ───
const wordCount = text.split(/\s+/).length;
let fontSizePercent;
if (wordCount <= 5) fontSizePercent = 0.075; // Short: 75px on 1024w
else if (wordCount <= 12) fontSizePercent = 0.065; // Medium: 66px
else fontSizePercent = 0.050; // Long: 51px
const fontSize = Math.round(img.width * fontSizePercent);
const outlineWidth = Math.round(fontSize * 0.15);
const maxWidth = img.width * 0.75;
const lineHeight = fontSize * 1.3;
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// ─── Word wrap ───
const lines = [];
const manualLines = text.split('\n');
for (const ml of manualLines) {
const words = ml.trim().split(/\s+/);
let current = '';
for (const word of words) {
const test = current ? `${current} ${word}` : word;
if (ctx.measureText(test).width <= maxWidth) {
current = test;
} else {
if (current) lines.push(current);
current = word;
}
}
if (current) lines.push(current);
}
// ─── Position: centered at ~28% from top ───
const totalHeight = lines.length * lineHeight;
const startY = (img.height * 0.28) - (totalHeight / 2);
const x = img.width / 2;
// ─── Draw each line ───
for (let i = 0; i < lines.length; i++) {
const y = startY + (i * lineHeight);
// Black outline
ctx.strokeStyle = '#000000';
ctx.lineWidth = outlineWidth;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(lines[i], x, y);
// White fill
ctx.fillStyle = '#FFFFFF';
ctx.fillText(lines[i], x, y);
}
fs.writeFileSync(outputPath, canvas.toBuffer('image/png'));
}Key details that make Larry's slides look professional:
- Dynamic font sizing — short text gets bigger (75px), long text gets smaller (51px). Every slide is optimized.
- Word wrap — respects manual
\nbreaks but also auto-wraps lines that exceed 75% width. No squashing. - Centered at 28% from top — text block is vertically centered around this point, not pinned to it. Stays in the safe zone regardless of line count.
- Thick outline — 15% of font size. Makes text readable on ANY background.
- Manual line breaks preferred — use
\nin your text for control. Keep lines to 4-6 words.
Text content rules:
- REACTIONS not labels — "Wait... this is actually nice??" not "Modern minimalist"
- 4-6 words per line — short lines are scannable at a glance
- 3-4 lines per slide is ideal
- No emoji — canvas can't render them reliably
- Safe zones: No text in bottom 20% (TikTok controls) or top 10% (status bar)
The difference between OK slides and viral slides is in these details. Larry's slides consistently hit 50K-150K+ views because the text is sized right, positioned right, and readable at a glance while scrolling.
⚠️ LINE BREAKS ARE CRITICAL — Read This:
The texts.json file must contain text with \n line breaks to control where lines wrap. If you pass a single long string without line breaks, the script will auto-wrap, but manual breaks look much better because you control the rhythm.
Good (manual breaks, 4-6 words per line):
[
"I showed my landlord\nwhat AI thinks our\nkitchen should look like",
"She said you can't\nchange anything\nchallenge accepted",
"So I downloaded\nthis app and\ntook one photo",
"Wait... is this\nactually the same\nkitchen??",
"Okay I'm literally\nobsessed with\nthis one",
"Snugly showed me\nwhat's possible\nlink in bio"
]Bad (no breaks — will auto-wrap but looks worse):
[
"I showed my landlord what AI thinks our kitchen should look like",
...
]Rules for writing overlay text: 1. 4-6 words per line MAX — short lines are scannable at a glance 2. Use `\n` to break lines — gives you control over the rhythm 3. 3-4 lines per slide is ideal — more lines are fine, they won't overflow 4. Read it out loud — each line should feel like a natural pause 5. No emoji — canvas can't render them, they'll show as blank 6. REACTIONS not labels — "Wait... this is nice??" not "Modern minimalist"
The script auto-wraps any line that exceeds 75% width as a safety net, but always prefer manual \n breaks for the best visual result.
3. Post to TikTok + Instagram
Use scripts/post-to-platforms.js:
node scripts/post-to-platforms.js --config tiktok-marketing/config.json --dir tiktok-marketing/posts/YYYY-MM-DD-HHmm/ --caption "caption" --title "title"This uploads all slide images and posts them to TikTok + Instagram (and any other configured platforms) simultaneously in a single API call via Upload-Post.
How it works: 1. Reads slide images from the directory (slide1.png through slideN.png) 2. Sends them to Upload-Post's POST /upload_photos endpoint 3. Includes all configured platforms in one request 4. Uses async_upload=true for background processing 5. Returns a request_id for tracking (saved in meta.json)
No manual video-ID linking needed. Upload-Post tracks posts automatically by request_id. The upload history endpoint returns per-platform post URLs and success/failure status.
Caption rules: Long storytelling captions (3x more views). Structure: Hook → Problem → Discovery → What it does → Result → max 5 hashtags. Conversational tone.
Why We Post TikTok Slideshows as Drafts — Best Practice
For TikTok specifically, posts go as photo carousels. TikTok photo posts benefit enormously from trending sounds:
1. Music is everything on TikTok. Trending sounds massively boost reach. The algorithm favours posts using popular audio. 2. After posting, add music from TikTok's sound library — browse what's trending in your niche. 3. Posts without music get buried. Silent slideshows look like ads and get skipped. A trending sound makes your content feel native.
This is the workflow that helped us hit 1M+ TikTok views and $670/month MRR. Don't skip the music step.
Instagram carousels don't need music — they work great as-is. Upload-Post handles both platforms with appropriate settings.
4. Track Analytics
Use scripts/check-analytics.js to pull platform analytics and upload history:
node scripts/check-analytics.js --config tiktok-marketing/config.json --days 3The script: 1. Fetches platform-level analytics (followers, impressions, reach, profile views) 2. Fetches upload history for the last N days 3. Groups uploads by request_id (one post = multiple platform entries) 4. Shows per-post success/failure status and post URLs 5. Saves a snapshot to analytics-snapshot.json
No connection step needed. Unlike systems that require manually linking post IDs, Upload-Post tracks everything automatically by request_id. When you upload, the history immediately shows which platforms received the post and their post URLs.
The daily cron handles all of this automatically. It runs in the morning, checks the last 3 days, and generates a comprehensive report.
---
The Feedback Loop (CRITICAL — This is What Makes It Work)
This is what separates "posting TikToks" from "running a marketing machine." The daily cron pulls data from two sources:
1. Upload-Post → platform analytics (followers, impressions, reach) + upload history (per-post success, post URLs) 2. RevenueCat (if connected) → conversion data (trial starts, paid subscriptions, revenue)
Combined, the agent can make intelligent decisions about what to do next — not guessing, not vibes, actual data-driven optimization.
The Daily Cron (Set Up During Onboarding)
Every morning before the first post, the cron runs scripts/daily-report.js:
1. Pulls platform analytics from Upload-Post (followers, impressions, reach timeseries) 2. Pulls upload history for the last 3 days (per-post status, post URLs) 3. If RevenueCat is connected, pulls conversion events in the same window (24-72h attribution) 4. Cross-references: which posts drove views AND which drove paying users 5. Applies the diagnostic framework (below) to determine what's working 6. Generates tiktok-marketing/reports/YYYY-MM-DD.md with findings 7. Messages the user with a summary + suggested hooks for today
The Diagnostic Framework
This is the core intelligence. Two axes: views (are people seeing it?) and conversions (are people paying?).
High views + High conversions → 🟢 SCALE IT
- This is working. Make 3 variations of the winning hook immediately
- Test different posting times to find the sweet spot
- Cross-post to more platforms for extra reach
- Don't change anything about the CTA — it's converting
High views + Low conversions → 🟡 FIX THE CTA
- The hook is doing its job — people are watching. But they're not downloading/subscribing
- Try different CTAs on slide 6 (direct vs subtle, "download" vs "search on App Store")
- Check if the app landing page matches the promise in the slideshow
- Test different caption structures — maybe the CTA is buried
- The hook is gold — don't touch it. Fix everything downstream
Low views + High conversions → 🟡 FIX THE HOOKS
- The people who DO see it are converting — the content and CTA are great
- But not enough people are seeing it, so the hook/thumbnail isn't stopping the scroll
- Test radically different hooks (person+conflict, POV, listicle, mistakes format)
- Try different posting times and different slide 1 images
- Keep the CTA and content structure identical — just change the hook
Low views + Low conversions → 🔴 FULL RESET
- Neither the hook nor the conversion path is working
- Try a completely different format or approach
- Research what's trending in the niche RIGHT NOW (use browser)
- Consider a different target audience angle
- Test new hook categories from scratch
- Reference competitor research for what's working for others
High views + High downloads + Low paying subscribers → 🔴 APP ISSUE
- The marketing is working. People are watching AND downloading. But they're not paying.
- This is NOT a content problem — the app onboarding, paywall, or pricing needs fixing.
- Check: Is the paywall shown at the right time? Is the free experience too generous?
- Check: Does the onboarding guide users to the "aha moment" before the paywall?
- Check: Is the pricing right? Too expensive for the perceived value?
- This is a signal to pause posting and fix the app experience first
High views + Low downloads → 🟡 CTA ISSUE
- People are watching but not downloading. The hooks work, the CTAs don't.
- Rotate through different CTAs: "link in bio", "search on App Store", app name only, "free to try"
- Check the App Store page — does it match what the TikTok shows?
- Check that "link in bio" actually works and goes to the right place
The daily report automates all of this. It cross-references platform impressions (Upload-Post) with downloads and revenue (RevenueCat) and tells you exactly which part of the funnel is broken — per post. It also auto-generates new hook suggestions based on your winning patterns and flags when CTAs need rotating.
Hook Evolution
Track in tiktok-marketing/hook-performance.json:
{
"hooks": [
{
"requestId": "abc123def456",
"text": "My boyfriend said our flat looks like a catalogue",
"date": "2026-02-15",
"platforms": {
"tiktok": { "success": true, "postUrl": "..." },
"instagram": { "success": true, "postUrl": "..." }
},
"conversions": 4,
"cta": "Download Snugly — link in bio",
"lastChecked": "2026-02-16"
}
],
"ctas": [
{
"text": "Download [App] — link in bio",
"timesUsed": 5,
"totalConversions": 8,
"conversionRate": 0.067
},
{
"text": "Search [App] on the App Store",
"timesUsed": 3,
"totalConversions": 12,
"conversionRate": 0.141
}
],
"rules": {
"doubleDown": ["person-conflict-ai"],
"testing": ["listicle", "pov-format"],
"dropped": ["self-complaint", "price-comparison"]
}
}The daily report updates this automatically. Each post gets tagged with its hook text, CTA, and attributed conversions. Over time, this builds a clear picture of which hook + CTA combinations actually drive revenue — not just views.
CTA rotation: When the report detects high impressions but low conversions, it automatically recommends rotating to a different CTA and tracks performance of each CTA separately. The agent should tag every post with the CTA used so the data accumulates.
Decision rules:
- Growing impressions + conversions → DOUBLE DOWN — make 3 variations immediately
- Steady impressions → Good — keep in rotation
- Declining impressions → Try 1 more variation
- Consistently low → DROP — try something radically different
CTA Testing
When views are good but conversions are low, cycle through CTAs:
- "Download [App] — link in bio"
- "[App] is free to try — link in bio"
- "I used [App] for this — link in bio"
- "Search [App] on the App Store"
- No explicit CTA (just app name visible)
Track which CTAs convert best per hook category.
---
Posting Schedule
Optimal times (adjust for audience timezone):
- 7:30 AM — catch early scrollers
- 4:30 PM — afternoon break
- 9:00 PM — evening wind-down
3x/day minimum. Consistency beats sporadic viral hits. 100 posts beats 1 viral.
Cross-Posting
Upload-Post supports posting the same content to 10+ platforms simultaneously in a single API call. Recommend:
- Instagram — especially strong for beauty/lifestyle/home (included by default)
- YouTube Shorts — long-tail discovery
- Threads — lightweight engagement driver
- LinkedIn — for B2B/professional apps
- Pinterest — strong for visual/home/design niches
Same slides, different algorithms, more surface area. Each platform's algo evaluates content independently. Upload-Post handles format requirements per platform automatically.
App Category Templates
See references/app-categories.md for category-specific slide prompts and hook formulas.
Common Mistakes
| Mistake | Fix |
|---|---|
| 1536x1024 (landscape) | Use 1024x1536 (portrait) |
| Font at 5% | Use 6.5% of width |
| Text at bottom | Position at 30% from top |
| Different rooms per slide | Lock architecture in EVERY prompt |
| Labels not reactions | "Wait this is nice??" not "Modern style" |
| Only tracking views | Track conversions — views without revenue = vanity |
| Same hooks forever | Iterate based on data, test new formats weekly |
| No cross-posting | Use Upload-Post to post everywhere simultaneously |
spawnSync ETIMEDOUT | Exec timeout too short — image gen takes 3-9 min for 6 slides. Use a 10-minute timeout or generate slides one at a time |
{
"ownerId": "kn781zbkdewedj9bk4frgp84th819bbh",
"slug": "larry",
"version": "1.0.0",
"publishedAt": 1771325231274
}# Generated content
tiktok-marketing/posts/
tiktok-marketing/reports/
tiktok-marketing/analytics-snapshot.json
tiktok-marketing/platform-stats.json
tiktok-marketing/rc-snapshot.json
tiktok-marketing/hook-performance.json
tiktok-marketing/hook-log.json
# Config with credentials
tiktok-marketing/config.json
# Node
node_modules/
package-lock.json
# OS
.DS_Store
Thumbs.db
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload-Post Larry Marketing Skill — AI-Powered TikTok & Instagram Marketing</title>
<meta name="description" content="Automate your TikTok & Instagram slideshow marketing with AI. Generate, overlay, post, track, and iterate — all powered by Upload-Post API.">
<meta property="og:title" content="Upload-Post Larry Marketing Skill">
<meta property="og:description" content="AI-powered TikTok + Instagram slideshow marketing. 7M+ views methodology, automated.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://upload-post.github.io/upload-post-larry-marketing-skill/">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎬</text></svg>">
<style>
:root {
--bg: #0a0a0f;
--surface: #12121a;
--border: #1e1e2e;
--text: #e4e4ef;
--text-muted: #8888a0;
--accent: #6c5ce7;
--accent-glow: rgba(108, 92, 231, 0.3);
--green: #00d2a0;
--green-glow: rgba(0, 210, 160, 0.2);
--orange: #ff7b54;
--red: #ff4757;
--yellow: #ffd43b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
.container { max-width: 1100px; margin: 0 auto; padding: 0 24px; }
/* Nav */
nav {
position: fixed; top: 0; left: 0; right: 0;
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
z-index: 100;
padding: 16px 0;
}
nav .container { display: flex; align-items: center; justify-content: space-between; }
.logo { font-size: 18px; font-weight: 700; color: var(--text); text-decoration: none; }
.logo span { color: var(--accent); }
.nav-links { display: flex; gap: 24px; align-items: center; }
.nav-links a { color: var(--text-muted); text-decoration: none; font-size: 14px; transition: color 0.2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta {
background: var(--accent); color: white !important; padding: 8px 20px;
border-radius: 8px; font-weight: 600; font-size: 14px !important;
transition: transform 0.2s, box-shadow 0.2s;
}
.nav-cta:hover { transform: translateY(-1px); box-shadow: 0 4px 20px var(--accent-glow); }
/* Hero */
.hero {
padding: 160px 0 100px;
text-align: center;
position: relative;
}
.hero::before {
content: '';
position: absolute;
top: 80px; left: 50%; transform: translateX(-50%);
width: 600px; height: 600px;
background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%);
pointer-events: none;
}
.badge {
display: inline-flex; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--border);
padding: 6px 16px; border-radius: 50px; font-size: 13px;
color: var(--text-muted); margin-bottom: 24px;
}
.badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
h1 {
font-size: clamp(36px, 6vw, 64px);
font-weight: 800;
line-height: 1.1;
margin-bottom: 20px;
letter-spacing: -0.02em;
}
h1 .gradient {
background: linear-gradient(135deg, var(--accent), var(--green));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 18px;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto 40px;
line-height: 1.7;
}
.hero-buttons { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
.btn-primary {
background: var(--accent);
color: white;
padding: 14px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 30px var(--accent-glow); }
.btn-secondary {
background: var(--surface);
color: var(--text);
padding: 14px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--border);
transition: border-color 0.2s, transform 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-secondary:hover { border-color: var(--accent); transform: translateY(-2px); }
/* Stats */
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
padding: 60px 0;
}
.stat {
text-align: center;
padding: 32px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
}
.stat-number {
font-size: 40px;
font-weight: 800;
background: linear-gradient(135deg, var(--accent), var(--green));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
/* Pipeline */
.pipeline { padding: 80px 0; }
.section-title {
font-size: 36px;
font-weight: 700;
text-align: center;
margin-bottom: 12px;
}
.section-subtitle {
text-align: center;
color: var(--text-muted);
font-size: 16px;
margin-bottom: 60px;
max-width: 550px;
margin-left: auto;
margin-right: auto;
}
.pipeline-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px 20px;
text-align: center;
position: relative;
transition: border-color 0.3s, transform 0.3s;
}
.step:hover { border-color: var(--accent); transform: translateY(-4px); }
.step-icon { font-size: 36px; margin-bottom: 12px; }
.step-title { font-weight: 700; font-size: 15px; margin-bottom: 6px; }
.step-desc { color: var(--text-muted); font-size: 13px; line-height: 1.5; }
.step-arrow {
position: absolute;
right: -14px;
top: 50%;
transform: translateY(-50%);
color: var(--accent);
font-size: 20px;
z-index: 2;
}
/* Feedback loop */
.feedback { padding: 80px 0; }
.feedback-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.feedback-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
transition: border-color 0.3s;
}
.feedback-card:hover { border-color: var(--accent); }
.feedback-card .emoji { font-size: 28px; margin-bottom: 12px; }
.feedback-card h3 { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
.feedback-card p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
.card-green { border-left: 3px solid var(--green); }
.card-yellow { border-left: 3px solid var(--yellow); }
.card-red { border-left: 3px solid var(--red); }
.card-orange { border-left: 3px solid var(--orange); }
/* Platforms */
.platforms { padding: 80px 0; text-align: center; }
.platform-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 40px;
}
.platform-tag {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 50px;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: border-color 0.2s, transform 0.2s;
}
.platform-tag:hover { border-color: var(--accent); transform: translateY(-2px); }
/* Code */
.quickstart { padding: 80px 0; }
.code-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
overflow-x: auto;
max-width: 700px;
margin: 0 auto;
}
.code-block pre {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.8;
color: var(--text-muted);
}
.code-block .comment { color: #555570; }
.code-block .cmd { color: var(--green); }
.code-block .flag { color: var(--accent); }
/* Skills section */
.skills-section { padding: 80px 0; }
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.skill-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
text-decoration: none;
color: var(--text);
transition: border-color 0.3s, transform 0.3s;
display: block;
}
.skill-card:hover { border-color: var(--accent); transform: translateY(-4px); }
.skill-card .card-icon { font-size: 32px; margin-bottom: 12px; }
.skill-card h3 { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
.skill-card p { color: var(--text-muted); font-size: 13px; line-height: 1.5; }
.skill-card .card-tag {
display: inline-block;
margin-top: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
background: rgba(108, 92, 231, 0.1);
padding: 4px 10px;
border-radius: 6px;
}
/* CTA */
.cta-section {
padding: 100px 0;
text-align: center;
}
.cta-box {
background: linear-gradient(135deg, rgba(108, 92, 231, 0.15), rgba(0, 210, 160, 0.1));
border: 1px solid var(--border);
border-radius: 24px;
padding: 60px 40px;
}
.cta-box h2 {
font-size: 32px;
font-weight: 700;
margin-bottom: 12px;
}
.cta-box p {
color: var(--text-muted);
margin-bottom: 32px;
font-size: 16px;
}
/* Footer */
footer {
border-top: 1px solid var(--border);
padding: 40px 0;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }
/* Responsive */
@media (max-width: 768px) {
.stats { grid-template-columns: 1fr; }
.feedback-grid { grid-template-columns: 1fr; }
.pipeline-steps { grid-template-columns: 1fr; }
.step-arrow { display: none; }
.nav-links a:not(.nav-cta) { display: none; }
.hero { padding: 120px 0 60px; }
}
</style>
</head>
<body>
<!-- Nav -->
<nav>
<div class="container">
<a href="https://upload-post.com" class="logo">Upload<span>-Post</span></a>
<div class="nav-links">
<a href="#pipeline">How It Works</a>
<a href="#quickstart">Quick Start</a>
<a href="#skills">All Skills</a>
<a href="https://github.com/Upload-Post/upload-post-larry-marketing-skill" class="nav-cta">⭐ GitHub</a>
</div>
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="container">
<div class="badge"><span class="dot"></span> Open Source · MIT Licensed</div>
<h1>AI Marketing on <span class="gradient">Autopilot</span></h1>
<p>An AI agent skill that generates TikTok & Instagram slideshows, posts to 10+ platforms, tracks analytics, and iterates on what works. Based on the Larry methodology.</p>
<div class="hero-buttons">
<a href="https://github.com/Upload-Post/upload-post-larry-marketing-skill" class="btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
View on GitHub
</a>
<a href="https://upload-post.com" class="btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Get Upload-Post API
</a>
</div>
</div>
</section>
<!-- Stats -->
<section>
<div class="container">
<div class="stats">
<div class="stat">
<div class="stat-number">7M+</div>
<div class="stat-label">Total views on content</div>
</div>
<div class="stat">
<div class="stat-number">10+</div>
<div class="stat-label">Platforms in one API call</div>
</div>
<div class="stat">
<div class="stat-number">$670</div>
<div class="stat-label">MRR from AI agent marketing</div>
</div>
</div>
</div>
</section>
<!-- Pipeline -->
<section class="pipeline" id="pipeline">
<div class="container">
<h2 class="section-title">The Pipeline</h2>
<p class="section-subtitle">From idea to published post across 10+ platforms — fully automated by your AI agent.</p>
<div class="pipeline-steps">
<div class="step">
<div class="step-icon">🔍</div>
<div class="step-title">Research</div>
<div class="step-desc">Analyze competitors on TikTok. What hooks work? What's missing?</div>
<span class="step-arrow">→</span>
</div>
<div class="step">
<div class="step-icon">🎨</div>
<div class="step-title">Generate</div>
<div class="step-desc">AI creates 6 photorealistic portrait slides via OpenAI, Stability, or Replicate.</div>
<span class="step-arrow">→</span>
</div>
<div class="step">
<div class="step-icon">✍️</div>
<div class="step-title">Overlay</div>
<div class="step-desc">Battle-tested text overlays with node-canvas. Sized for virality.</div>
<span class="step-arrow">→</span>
</div>
<div class="step">
<div class="step-icon">🚀</div>
<div class="step-title">Publish</div>
<div class="step-desc">One Upload-Post API call → TikTok, Instagram, YouTube, X, LinkedIn & more.</div>
<span class="step-arrow">→</span>
</div>
<div class="step">
<div class="step-icon">📊</div>
<div class="step-title">Track & Iterate</div>
<div class="step-desc">Daily analytics + feedback loop. Data-driven hook optimization.</div>
</div>
</div>
</div>
</section>
<!-- Feedback Loop -->
<section class="feedback">
<div class="container">
<h2 class="section-title">The Intelligence Loop</h2>
<p class="section-subtitle">Not just "post and pray." Every day, the agent analyzes what's working and adjusts.</p>
<div class="feedback-grid">
<div class="feedback-card card-green">
<div class="emoji">🟢</div>
<h3>High Views + High Conversions</h3>
<p>SCALE IT — make 3 variations of the winning hook. Test different posting times. Cross-post everywhere.</p>
</div>
<div class="feedback-card card-yellow">
<div class="emoji">🟡</div>
<h3>High Views + Low Conversions</h3>
<p>FIX THE CTA — the hook works, but people aren't converting. Try different calls-to-action on the last slide.</p>
</div>
<div class="feedback-card card-orange">
<div class="emoji">🟠</div>
<h3>Low Views + High Conversions</h3>
<p>FIX THE HOOKS — content converts great, just needs more eyeballs. Test radically different hooks.</p>
</div>
<div class="feedback-card card-red">
<div class="emoji">🔴</div>
<h3>Low Views + Low Conversions</h3>
<p>FULL RESET — try a completely different format, approach, or audience angle. Research what's trending now.</p>
</div>
</div>
</div>
</section>
<!-- Platforms -->
<section class="platforms">
<div class="container">
<h2 class="section-title">One API Call. Every Platform.</h2>
<p class="section-subtitle">Upload-Post handles posting to all platforms simultaneously. Same content, different algorithms, maximum reach.</p>
<div class="platform-grid">
<div class="platform-tag">🎵 TikTok</div>
<div class="platform-tag">📸 Instagram</div>
<div class="platform-tag">▶️ YouTube</div>
<div class="platform-tag">💼 LinkedIn</div>
<div class="platform-tag">🐦 X (Twitter)</div>
<div class="platform-tag">📘 Facebook</div>
<div class="platform-tag">🧵 Threads</div>
<div class="platform-tag">📌 Pinterest</div>
<div class="platform-tag">🤖 Reddit</div>
<div class="platform-tag">🦋 Bluesky</div>
</div>
</div>
</section>
<!-- Quick Start -->
<section class="quickstart" id="quickstart">
<div class="container">
<h2 class="section-title">Quick Start</h2>
<p class="section-subtitle">Install the skill and let your AI agent handle the rest.</p>
<div class="code-block">
<pre><span class="comment"># Install as an AI agent skill</span>
<span class="cmd">npx skills add</span> <span class="flag">Upload-Post/upload-post-larry-marketing-skill</span>
<span class="comment"># Or clone directly</span>
<span class="cmd">git clone</span> <span class="flag">https://github.com/Upload-Post/upload-post-larry-marketing-skill.git</span>
<span class="comment"># Initialize workspace</span>
<span class="cmd">node scripts/onboarding.js</span> <span class="flag">--init --dir tiktok-marketing/</span>
<span class="comment"># Your AI agent handles everything from here:</span>
<span class="comment"># → Competitor research</span>
<span class="comment"># → Image generation + text overlays</span>
<span class="comment"># → Multi-platform posting via Upload-Post</span>
<span class="comment"># → Daily analytics + optimization loop</span></pre>
</div>
</div>
</section>
<!-- All Skills -->
<section class="skills-section" id="skills">
<div class="container">
<h2 class="section-title">Upload-Post Ecosystem</h2>
<p class="section-subtitle">Tools, SDKs, and skills to power your social media automation.</p>
<div class="skills-grid">
<a href="https://github.com/Upload-Post/upload-post-larry-marketing-skill" class="skill-card">
<div class="card-icon">🎬</div>
<h3>Larry Marketing Skill</h3>
<p>AI-powered TikTok & Instagram slideshow marketing. Generate, overlay, post, track, iterate.</p>
<span class="card-tag">AI Agent Skill</span>
</a>
<a href="https://github.com/Upload-Post/upload-post-skill" class="skill-card">
<div class="card-icon">🤖</div>
<h3>Upload-Post Skill</h3>
<p>General Upload-Post skill for AI agents. Upload videos, photos, carousels, schedule posts.</p>
<span class="card-tag">AI Agent Skill</span>
</a>
<a href="https://github.com/Upload-Post/upload-post-npm" class="skill-card">
<div class="card-icon">📦</div>
<h3>Node.js SDK</h3>
<p>Official JavaScript/Node.js SDK for the Upload-Post API. npm install upload-post.</p>
<span class="card-tag">SDK</span>
</a>
<a href="https://github.com/Upload-Post/upload-post-pip" class="skill-card">
<div class="card-icon">🐍</div>
<h3>Python SDK</h3>
<p>Official Python SDK for the Upload-Post API. pip install upload-post.</p>
<span class="card-tag">SDK</span>
</a>
<a href="https://github.com/Upload-Post/n8n-nodes-upload-post" class="skill-card">
<div class="card-icon">🔗</div>
<h3>n8n Community Node</h3>
<p>Upload-Post node for n8n workflows. Drag-and-drop social media automation.</p>
<span class="card-tag">Integration</span>
</a>
<a href="https://upload-post.com" class="skill-card">
<div class="card-icon">🚀</div>
<h3>Upload-Post API</h3>
<p>The engine behind it all. One API call to post to 10+ social media platforms simultaneously.</p>
<span class="card-tag">Platform</span>
</a>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta-section">
<div class="container">
<div class="cta-box">
<h2>Ready to automate your marketing?</h2>
<p>Get an Upload-Post API key, install the skill, and let your AI agent do the rest.</p>
<div class="hero-buttons">
<a href="https://upload-post.com" class="btn-primary">Get Started Free →</a>
<a href="https://github.com/Upload-Post/upload-post-larry-marketing-skill" class="btn-secondary">View Source Code</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer>
<div class="container">
<p>Built with ❤️ by <a href="https://upload-post.com">Upload-Post</a> · <a href="https://github.com/Upload-Post">GitHub</a> · MIT License</p>
</div>
</footer>
</body>
</html>
MIT License
Copyright (c) 2026 TONVI TECH SL
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
📱 TikTok App Marketing Skill
Automate your entire TikTok + Instagram slideshow marketing pipeline with AI: generate → overlay → post → track → iterate.
An OpenClaw / AI agent skill that turns your app marketing into a data-driven machine. Based on Larry's methodology that achieved 7M+ views, 1M+ TikTok views, and $670/month MRR — all from an AI agent.
What It Does
1. Researches competitors — browser-based analysis of what's working in your niche 2. Generates AI images — photorealistic slideshow slides (OpenAI, Stability AI, Replicate, or bring your own) 3. Adds text overlays — node-canvas powered, battle-tested sizing and positioning from 100+ viral posts 4. Posts to 10+ platforms simultaneously — via Upload-Post API (TikTok, Instagram, YouTube, LinkedIn, X, Threads, Pinterest, Reddit, Bluesky) 5. Tracks analytics — platform-level stats + per-post performance via Upload-Post 6. Iterates based on data — daily feedback loop that tells you what's working and what to change 7. Optional: Tracks conversions — RevenueCat integration for full funnel intelligence (views → downloads → paying users)
Quick Start
Prerequisites
- Node.js v18+
- node-canvas (
npm install canvas) — for text overlays - [Upload-Post](https://upload-post.com) account — handles multi-platform posting + analytics
- Image generation API (pick one): OpenAI, Stability AI, Replicate, or local images
Installation
If you're using OpenClaw:
# Install as a skill
npx skills add Upload-Post/upload-post-larry-marketing-skillOr clone directly:
git clone https://github.com/Upload-Post/upload-post-larry-marketing-skill.git
cd tiktok-marketing-skill
npm install canvasSetup
1. Initialize the workspace:
node scripts/onboarding.js --init --dir tiktok-marketing/2. Fill in `tiktok-marketing/config.json`:
{
"app": {
"name": "YourApp",
"description": "What your app does",
"audience": "Who it's for",
"problem": "What pain it solves",
"category": "home|beauty|fitness|productivity|food|other"
},
"imageGen": {
"provider": "openai",
"apiKey": "YOUR_OPENAI_KEY",
"model": "gpt-image-1.5"
},
"uploadPost": {
"apiKey": "YOUR_UPLOAD_POST_KEY",
"profile": "your_profile",
"platforms": ["tiktok", "instagram"]
}
}3. Validate your config:
node scripts/onboarding.js --validate --config tiktok-marketing/config.jsonCreate Your First Post
# 1. Generate 6 slideshow images
node scripts/generate-slides.js \
--config tiktok-marketing/config.json \
--output tiktok-marketing/posts/my-first-post/ \
--prompts prompts.json
# 2. Add text overlays
node scripts/add-text-overlay.js \
--input tiktok-marketing/posts/my-first-post/ \
--texts texts.json
# 3. Post to TikTok + Instagram
node scripts/post-to-platforms.js \
--config tiktok-marketing/config.json \
--dir tiktok-marketing/posts/my-first-post/ \
--caption "Your caption here #hashtag1 #hashtag2"
# 4. Check analytics
node scripts/check-analytics.js --config tiktok-marketing/config.json --days 3The Feedback Loop
This is what makes the skill actually work. It's not just "post and pray" — it's a data-driven optimization engine.
Every morning, the daily report: 1. Pulls platform analytics from Upload-Post (followers, impressions, reach) 2. Checks upload history (per-post success/failure, post URLs) 3. If RevenueCat connected: pulls conversion data (trials, subscribers, revenue) 4. Applies the diagnostic framework:
| Views | Conversions | Action |
|---|---|---|
| 🟢 High | 🟢 High | SCALE IT — make 3 variations of the winning hook |
| 🟢 High | 🔴 Low | FIX THE CTA — hook works, downstream is broken |
| 🔴 Low | 🟢 High | FIX THE HOOKS — content converts, needs more eyeballs |
| 🔴 Low | 🔴 Low | FULL RESET — try radically different approach |
node scripts/daily-report.js --config tiktok-marketing/config.json --days 3File Structure
tiktok-marketing-skill/
├── SKILL.md # Full skill documentation (for AI agents)
├── _meta.json # Skill metadata
├── scripts/
│ ├── onboarding.js # Config validator + directory initializer
│ ├── generate-slides.js # Image generation (OpenAI/Stability/Replicate/local)
│ ├── add-text-overlay.js # Text overlay with node-canvas
│ ├── post-to-platforms.js # Multi-platform posting via Upload-Post
│ ├── check-analytics.js # Analytics + upload history checker
│ ├── daily-report.js # Daily marketing report with diagnostics
│ └── competitor-research.js # Research helper (placeholder)
└── references/
├── slide-structure.md # 6-slide formula + hook writing guide
├── app-categories.md # Category-specific templates
├── analytics-loop.md # Analytics API reference
├── competitor-research.md # Research methodology
└── revenuecat-integration.md # RevenueCat setup guideWhy Upload-Post?
Upload-Post is the engine that powers multi-platform posting and analytics:
- One API call → 10+ platforms — TikTok, Instagram, YouTube, LinkedIn, X, Threads, Pinterest, Reddit, Bluesky
- Automatic tracking — every post gets a
request_id, no manual video-ID linking needed - Platform analytics — followers, impressions, reach, profile views with timeseries data
- Upload history — per-post success/failure, post URLs, platform-specific IDs
- Async processing — submit and check status later
Sign up at upload-post.com
Proven Hook Formulas
Tier 1: Person + Conflict → AI → Changed Mind (BEST)
- "I showed my mum what AI thinks our kitchen should look like" (161K views)
- "My landlord said I can't change anything so I showed her this" (124K views)
Tier 2: Relatable Budget Pain
- "POV: You have good taste but no budget"
- "I can't afford an interior designer so I tried AI"
See references/slide-structure.md for the complete hook writing guide.
Tips
- Always use portrait (9:16) — fills the TikTok screen
- Add trending music — TikTok posts go to inbox as drafts, add a trending sound before publishing
- 3 posts/day minimum — consistency beats sporadic viral hits
- Use `gpt-image-1.5` if using OpenAI — never
gpt-image-1(massive quality difference) - Cross-post everything — same content, different algorithms, more surface area
License
MIT
Credits
Based on Larry's TikTok marketing methodology. Adapted for Upload-Post multi-platform API.
Analytics & Feedback Loop
Performance Tracking
Upload-Post Analytics API
Platform analytics (followers, impressions, reach, profile views):
GET https://api.upload-post.com/api/analytics/{profile}?platforms=tiktok,instagram
Authorization: Apikey {apiKey}Response:
{
"tiktok": {
"followers": 1071,
"reach": 0,
"impressions": 6522,
"profileViews": 0,
"reach_timeseries": [
{ "date": "2026-02-11", "value": 773 },
{ "date": "2026-02-12", "value": 190 }
]
},
"instagram": {
"followers": 523,
"impressions": 12500,
"profileViews": 89,
"reach": 8700,
"reach_timeseries": [...]
}
}Upload history (per-post tracking with request_ids):
GET https://api.upload-post.com/api/uploadposts/history?page=1&limit=50&profile_username={profile}
Authorization: Apikey {apiKey}Response:
{
"history": [
{
"profile_username": "upload_post",
"platform": "tiktok",
"media_type": "image",
"upload_timestamp": "2026-02-15T14:30:00Z",
"success": true,
"platform_post_id": "7605531854921354518",
"post_url": "https://www.tiktok.com/@user/video/7605531854921354518",
"post_title": "Caption text...",
"request_id": "abc123def456",
"request_total_platforms": 2
}
]
}Upload status (check async upload progress):
GET https://api.upload-post.com/api/uploadposts/status?request_id={request_id}
Authorization: Apikey {apiKey}Key Difference from Postiz
Upload-Post automatically tracks posts by request_id. No manual video-ID linking is needed:
- When you upload via
POST /upload_photos, you get arequest_id - Upload history includes the
request_id, platform-specific post IDs, and post URLs - Analytics are tracked at the platform level (followers, impressions, reach)
- No release-ID connection step — Upload-Post handles it automatically
RevenueCat Integration (Optional)
If the user has RevenueCat, track conversions from TikTok:
- Downloads → Trial starts → Paid conversions
- UTM parameters in App Store link
- Compare conversion spikes with post timing
The Feedback Loop
After Every Post (24h)
Record in hook-performance.json:
{
"hooks": [
{
"requestId": "abc123def456",
"text": "boyfriend said flat looks like catalogue",
"date": "2026-02-15",
"platforms": {
"tiktok": { "success": true, "postUrl": "..." },
"instagram": { "success": true, "postUrl": "..." }
},
"conversions": 4,
"cta": "Download App — link in bio",
"lastChecked": "2026-02-16"
}
]
}Weekly Review
1. Check platform analytics deltas (impressions, followers growth) 2. Review upload history for successes/failures 3. Identify top hooks by conversion rate (if RevenueCat connected) 4. Check if any hook CATEGORY consistently wins 5. Update prompt templates with learnings
Decision Rules (based on platform impressions + conversions)
| Impressions Trend | Action |
|---|---|
| Growing (5K+/day) | DOUBLE DOWN — make 3 variations immediately |
| Steady (1K-5K/day) | Good — keep in rotation, test tweaks |
| Declining (<1K/day) | Try radically different approach |
What to Vary When Iterating
- Same hook, different person: "landlord" → "mum" → "boyfriend"
- Same structure, different room/feature: bedroom → kitchen → bathroom
- Same images, different text: proven images can be reused with new hooks
- Same hook, different time: morning vs evening posting
Conversion Tracking
Funnel
Views → Profile Visits → Link Clicks → App Store → Download → Trial → PaidBenchmarks
- 1% conversion (views → download) = average
- 1.5-3% = good
- 3%+ = great
Attribution Tips
- Track download spikes within 24h of viral post
- Use unique UTM links per campaign if possible
- RevenueCat
$attributionfor source tracking - Compare weekly MRR growth with weekly view totals
Daily Analytics Cron
Set up a cron job to run every morning before the first post (e.g. 7:00 AM user's timezone):
Task: node scripts/daily-report.js --config tiktok-marketing/config.json --days 3
Output: tiktok-marketing/reports/YYYY-MM-DD.mdThe daily report: 1. Fetches platform analytics from Upload-Post (impressions, followers, reach timeseries) 2. Fetches upload history for the last N days (per-post success/failure, post URLs) 3. If RevenueCat is connected, pulls conversion events (trials, purchases) in the same window 4. Cross-references: maps conversion timestamps to post upload times (24-72h attribution window) 5. Applies the diagnostic framework:
- High views + High conversions → SCALE (make variations)
- High views + Low conversions → FIX CTA (hook works, downstream is broken)
- Low views + High conversions → FIX HOOKS (content converts, needs more eyeballs)
- Low views + Low conversions → FULL RESET (try radically different approach)
6. Suggests new hooks based on what's working 7. Updates hook-performance.json with latest data 8. Messages the user with a summary
Why 3 Days?
- TikTok posts peak at 24-48 hours (not instant like Twitter)
- Conversion attribution takes up to 72 hours (user sees post → downloads → trials → pays)
- 3-day window captures the full lifecycle of each post
Multi-Platform Advantage
Upload-Post sends to TikTok + Instagram (and any other connected platforms) in a single API call. The analytics endpoint returns per-platform data, so you can compare performance across platforms and identify where your content resonates most.
App Category Templates
Home / Interior Design
Slide concept: Same room, same angle, different interior styles.
Base prompt template (adapt wording for your image gen provider):
Realistic photo of a [STYLE] [room type] in a small flat. [Room dimensions].
Shot from [position]. [Window details]. [Door details]. [Key furniture with exact
positions]. [Floor type]. [Ceiling details]. Natural lighting, phone camera quality.
Portrait orientation."Before" rules:
- Modern but tired, NOT ancient
- Include flat screen TV on wall
- Everyday items: mugs, remote, magazines
- Magnolia walls, basic curtains, no decor
Style transforms: Mid-century modern, Scandinavian, Industrial luxe, Coastal, Japandi, Maximalist bohemian
---
Beauty / Cosmetics
Slide concept: Same face, same angle, progressive enhancement.
Base prompt template:
Close-up portrait photo of a young woman, [age], [ethnicity], [features],
[hair], [makeup level]. [Expression], looking directly at camera. Natural
indoor lighting from window to the left. Plain wall background. Phone selfie
quality, natural skin texture with visible pores. Portrait orientation.Preservation rules:
- Face shape PIXEL-PERFECT identical
- Nose, eyes EXACTLY the same
- ALL skin texture preserved (pores, freckles)
- NO skin smoothing or beauty filters
- ONLY change the target feature (lips, lashes, etc.)
---
Fitness / Body
Slide concept: Same person, same pose, progressive transformation.
Base prompt: Mirror selfie or gym photo, consistent lighting and background.
Transform progression: Current → 1 month → 3 months → 6 months (subtle, believable changes)
---
Productivity / SaaS
Slide concept: Before/after workflow visualization OR results demonstration.
Approach: Can use screenshots with text overlays rather than AI images. Show the messy "before" (spreadsheets, chaos) vs clean "after" (organized, automated).
---
Food / Recipe
Slide concept: Same dish, different presentations or same ingredients, different meals.
Base prompt: Overhead shot of food on table, consistent tableware and background.
Competitor Research Guide
Why This Matters
Before creating content, you MUST understand the landscape. What hooks are competitors using? What's getting views? What gaps exist? This research directly drives your hook strategy and content differentiation.
Research Process
1. Ask for Browser Permission
Always ask the user before browsing. Something like:
"I want to research what your competitors are doing on TikTok — what's getting views, what hooks they use, what's working. Can I use the browser to look around?"
2. TikTok Research
Search TikTok for the app's niche. Look for:
- Competitor accounts posting similar content (aim for 3-5)
- Top-performing videos in the niche — what hooks do they use?
- Slide formats — before/after, listicle, POV, tutorial, reaction
- View counts — what's average vs exceptional in this niche?
- Posting frequency — how often do successful accounts post?
- CTAs — "link in bio", "search on App Store", app name in text, etc.
- Trending sounds — what music/sounds are popular in this niche?
- Comment sentiment — what do people ask about? What complaints?
3. App Store Research
Check the app's category on App Store / Google Play:
- Competitor apps in the same category
- Their screenshots, descriptions, ratings
- What features they highlight
- Their pricing model (free, freemium, subscription)
- Review sentiment — what do users love/hate about competitors?
4. Gap Analysis
The most valuable output is identifying what competitors AREN'T doing:
- Content gaps: Formats no one is using (listicles? tutorials? comparisons?)
- Hook gaps: Emotional angles no one has tried
- Platform gaps: Are competitors only on TikTok? Instagram opportunity?
- Audience gaps: Is there an underserved segment?
- Quality gaps: Are competitor images/videos low effort? Can we do better?
5. Save Findings
Store in tiktok-marketing/competitor-research.json:
{
"researchDate": "2026-02-16",
"competitors": [
{
"name": "CompetitorApp",
"tiktokHandle": "@competitor",
"followers": 50000,
"topHooks": ["hook text 1", "hook text 2"],
"avgViews": 15000,
"bestVideo": {
"views": 500000,
"hook": "The hook that went viral",
"format": "before-after slideshow",
"url": "https://tiktok.com/..."
},
"format": "before-after slideshows",
"postingFrequency": "daily",
"cta": "link in bio",
"strengths": "Great visuals, consistent posting",
"weaknesses": "Same hooks every time, no storytelling"
}
],
"nicheInsights": {
"trendingSounds": ["sound name 1"],
"commonFormats": ["before-after", "POV"],
"averageViews": 15000,
"topPerformingViews": 500000,
"gapOpportunities": "Nobody is doing person+conflict hooks in this niche",
"avoidPatterns": "Price comparison posts get <1K views consistently"
}
}6. Share Findings Conversationally
Don't dump the JSON. Talk about it:
"So I looked at what's out there in [niche]. The main players are [A], [B], and [C]. [A] is doing well with [format] — their best post got [X] views. But I noticed nobody's really doing [gap]. That's where I think we can win. Here's my plan..."
Ongoing Research
Don't just research once. During weekly reviews:
- Check if competitors have posted new viral content
- Look for new entrants in the niche
- Monitor trending sounds and formats
- Update
competitor-research.jsonwith new findings
Reference competitor data when suggesting hooks — "Competitor X got 200K views with a landlord hook, let's try our version."
RevenueCat Integration
Setup
Add RevenueCat config to config.json:
{
"revenuecat": {
"v1SecretKey": "sk_...",
"projectId": "your-project-id"
}
}Get the V1 Secret API Key from RevenueCat Dashboard → Project Settings → API Keys. Use the secret key (sk_), NOT the public key.
API Endpoints
Get Overview Metrics
RevenueCat doesn't expose dashboard overview via API. Use the V1 subscriber endpoint to track individual conversions, or scrape the dashboard via browser automation.
Alternative: Webhooks. Set up RevenueCat webhooks to log events (trial_started, initial_purchase, renewal, cancellation) to a local JSON file that the skill can read.
Get Subscriber Info (V1)
GET https://api.revenuecat.com/v1/subscribers/{app_user_id}
Authorization: Bearer {v1SecretKey}Returns: active subscriptions, entitlements, purchase history, management URL.
List Subscribers (V2 — if available)
GET https://api.revenuecat.com/v2/projects/{projectId}/customers
Authorization: Bearer {v2SecretKey}Daily Report Script
scripts/daily-report.js runs daily to:
1. Pull platform analytics from Upload-Post (last 3 days of posts) 2. Pull conversion data from RevenueCat webhook logs OR manual input 3. Cross-reference post timing with conversion spikes 4. Generate report identifying which hooks drove actual revenue
Cross-Reference Logic
For each day in last 3 days:
1. Get all TikTok posts and their view counts
2. Get all new trials + paid conversions from RevenueCat
3. Correlate: conversion spikes within 24h of high-view posts
4. Score each hook: (conversions in 24h window) / (views / 1000) = conversion rate per 1K views
5. Rank hooks by conversion rate, not just viewsWhy 3 Days?
- TikTok posts peak at 24-48h then tail off
- Conversion attribution window is ~24-72h
- Shorter = miss delayed conversions, longer = too noisy
Webhook Setup (Recommended)
In RevenueCat Dashboard → Project Settings → Webhooks:
1. Set webhook URL to your server OR log to file 2. Events to track:
INITIAL_PURCHASE— new paid subscriberTRIAL_STARTED— new trialTRIAL_CONVERTED— trial → paidRENEWAL— existing subscriber renewedCANCELLATION— subscriber cancelledEXPIRATION— subscription expired
Store events in tiktok-marketing/rc-events.json:
[
{
"event": "INITIAL_PURCHASE",
"timestamp": "2026-02-15T14:00:00Z",
"product": "fullAccessMonthly",
"revenue": 4.99,
"currency": "USD"
}
]If no webhook available, the user can manually update this file or the agent can prompt for daily numbers:
- "How many new trials today?"
- "How many paid conversions?"
- "Current MRR?"
Report Output
The daily report generates tiktok-marketing/reports/YYYY-MM-DD.md:
# Daily Marketing Report — Feb 15, 2026
## TikTok Performance (Last 3 Days)
| Date | Hook | Views | Likes | Saves |
|------|------|-------|-------|-------|
| Feb 15 | boyfriend + catalogue | 12,400 | 340 | 67 |
| Feb 14 | sister prison cell | 8,200 | 215 | 43 |
| Feb 13 | nan hook | 3,100 | 89 | 12 |
## Conversions (Last 3 Days)
- New trials: 14
- Trial → Paid: 6
- New direct purchases: 2
- Revenue: $47.92
## Attribution
- Feb 15 spike (8 trials) correlates with "boyfriend + catalogue" post (12.4K views)
- Estimated conversion rate: 0.65 per 1K views (GOOD)
## Recommendations
- DOUBLE DOWN on relationship conflict hooks (boyfriend/sister/nan)
- Drop listicle format (Feb 13 — low views, 0 correlating conversions)
- Test: "My [person] didn't believe AI could redesign our [room]"Slide Structure & Hook Writing
The 6-Slide Formula (EXACTLY 6 — TikTok minimum)
| Slide | Purpose | Text Style |
|---|---|---|
| 1 | HOOK — stop the scroll | Relatable problem, full hook text |
| 2 | PROBLEM — amplify pain | Build tension |
| 3 | DISCOVERY — turning point | "So I tried this" / "Then I found..." |
| 4 | TRANSFORMATION 1 — first result | Reaction: "Wait... this actually looks good?" |
| 5 | TRANSFORMATION 2 — escalate | Reaction: "Okay I'm obsessed" |
| 6 | CTA — call to action | App name + "link in bio" |
SAME subject, SAME angle, DIFFERENT styles across all 6 slides.
Proven Hook Formulas
Tier 1: Person + Conflict → AI → Changed Mind (BEST)
- "I showed my mum what AI thinks our [room] should look like" (161K views)
- "My landlord said I can't change anything so I showed her this" (124K views)
- "My boyfriend said our flat looks like [insult] so I showed him"
- "My flatmate wouldn't believe this is the same room"
Tier 2: Relatable Budget Pain
- "POV: You have good taste but no budget"
- "IKEA budget, designer taste"
- "I can't afford an interior designer so I tried AI"
Tier 3: Curiosity / Self-Discovery
- "I've always wondered what I'd look like with..."
- "I had to see if it would even suit me"
- "Everyone's getting [thing] but would it suit MY face?"
What DOESN'T Work
- Self-focused complaints without conflict: "My flat is ugly" (low views)
- Fear/insecurity hooks for beauty: "Am I ugly without..." (people scroll past)
- Price comparison without story: "$500 vs $5000" (needs character)
Hook Adaptation by Category
Home/Interior Apps
Replace [room] and [style] with user's app focus:
- "My [person] said our [room] looks like [insult]"
- "I showed my [person] what AI thinks our [room] should look like"
- "[Person] wouldn't let me redecorate until I showed them this"
Beauty Apps
- "My [person] got [treatment] and now I can't stop thinking about it"
- "I've always had [feature] and never known what [change] would look like"
- "Everyone keeps asking if I got [treatment] done"
Fitness Apps
- "My trainer said I'd never look like [goal]"
- "I showed my gym buddy what AI thinks I could look like in 6 months"
Productivity Apps
- "My boss said my workflow is a mess so I showed her this"
- "I was spending 4 hours on [task] until I found this"
Image Prompt Template
Write ONE base description, reuse across all 6 slides:
[For AI image gen providers:]
iPhone photo of a [CONTEXT]. [DETAILED DESCRIPTION OF SUBJECT].
Shot from [CAMERA POSITION]. [SPECIFIC ARCHITECTURAL/PHYSICAL DETAILS].
Natural phone camera quality, realistic lighting. Portrait orientation.
[SLIDE-SPECIFIC STYLE CHANGES ONLY]Adapt prompt style to your image gen provider — the key principles (same subject, same angle, different styles) apply regardless of which tool generates the images.
What to Lock (same across all 6):
- Subject dimensions/features
- Camera angle/position
- Lighting direction
- Background elements
- Physical structure (windows, doors, body proportions)
What Changes Per Slide (ONLY):
- Style/aesthetic
- Colors/textures
- Decor/accessories
- Expression (for faces)
Caption Template
[hook matching slide 1] 😭 [2-3 sentences of relatable struggle].
So I found this app called [APP NAME] that [what it does in one sentence] -
you just [simple action] and it [result]. I tried [style 1] and [style 2]
and honestly?? [emotional reaction]. [funny/relatable closer]
#[niche1] #[niche2] #[niche3] #[niche4] #fypKeep it conversational. Tell a mini-story. Mention the app naturally, not salesy.
Music (CRITICAL — Do NOT Skip)
Posts are published as drafts (SELF_ONLY) to TikTok inbox. Before publishing:
1. Open the draft in TikTok 2. Tap "Add sound" and browse trending sounds in your niche 3. Pick something popular — trending audio gets algorithmic boost 4. Preview the slideshow with the sound, then publish
Why drafts? TikTok's algorithm massively favours posts with trending sounds. Silent slideshows look like ads and get buried. Adding the right music is the difference between 1K and 100K views. An API can't pick what's trending right now — you need to browse the sound library.
This takes 30 seconds per post. Don't skip it.
#!/usr/bin/env node
/**
* Add text overlays to slideshow images using node-canvas.
*
* Usage: node add-text-overlay.js --input <dir> --texts <texts.json>
*
* texts.json format:
* [
* "Slide 1 text with manual\nline breaks preferred",
* "Slide 2 text",
* ... 6 total
* ]
*
* TEXT RULES:
* - Use \n for manual line breaks (PREFERRED — gives you control)
* - If no \n provided, the script auto-wraps to fit within maxWidth
* - Keep lines to 4-6 words max for readability
* - Text is REACTIONS not labels ("Wait... this is nice??" not "Modern style")
* - No emoji (canvas can't render them)
*
* Reads slide1_raw.png through slide6_raw.png (or slide_1.png etc)
* Outputs slide1.png through slide6.png (or final_1.png etc)
*/
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const inputDir = getArg('input');
const textsPath = getArg('texts');
if (!inputDir || !textsPath) {
console.error('Usage: node add-text-overlay.js --input <dir> --texts <texts.json>');
process.exit(1);
}
const texts = JSON.parse(fs.readFileSync(textsPath, 'utf-8'));
if (texts.length !== 6) {
console.error('ERROR: texts.json must have exactly 6 entries');
process.exit(1);
}
/**
* Word-wrap text to fit within maxWidth.
* If the text already contains \n, splits on those first,
* then wraps any lines that are still too wide.
*/
function wrapText(ctx, text, maxWidth) {
// Strip emoji (canvas can't render them reliably)
const cleanText = text.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '').trim();
// Split on manual line breaks first
const manualLines = cleanText.split('\n');
const wrappedLines = [];
for (const line of manualLines) {
// Check if this line fits as-is
if (ctx.measureText(line.trim()).width <= maxWidth) {
wrappedLines.push(line.trim());
continue;
}
// Auto-wrap: split into words, build lines that fit
const words = line.trim().split(/\s+/);
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (ctx.measureText(testLine).width <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) wrappedLines.push(currentLine);
currentLine = word;
}
}
if (currentLine) wrappedLines.push(currentLine);
}
return wrappedLines;
}
async function addTextOverlay(imgPath, text, outPath) {
const img = await loadImage(imgPath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ─── Text settings (match our proven viral format) ───
const fontSize = Math.round(img.width * 0.065); // 6.5% of image width (~66px on 1024w)
const outlineWidth = Math.round(fontSize * 0.15); // 15% of font size for thick outline
const maxWidth = img.width * 0.75; // 75% of image width (padding for TikTok UI)
const lineHeight = fontSize * 1.25; // 125% line height for readability
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Wrap text to fit within maxWidth
const lines = wrapText(ctx, text, maxWidth);
// Calculate vertical position
// Center the text block at 30% from top
const totalTextHeight = lines.length * lineHeight;
const startY = (img.height * 0.30) - (totalTextHeight / 2) + (lineHeight / 2);
// Ensure text stays in safe zones (not top 10%, not bottom 20%)
const minY = img.height * 0.10;
const maxY = img.height * 0.80 - totalTextHeight;
const safeY = Math.max(minY, Math.min(startY, maxY));
const x = img.width / 2; // Center horizontally
for (let i = 0; i < lines.length; i++) {
const y = safeY + (i * lineHeight);
// Black outline (stroke first, then fill on top)
ctx.strokeStyle = '#000000';
ctx.lineWidth = outlineWidth;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(lines[i], x, y);
// White fill
ctx.fillStyle = '#FFFFFF';
ctx.fillText(lines[i], x, y);
}
fs.writeFileSync(outPath, canvas.toBuffer('image/png'));
// Log the actual text layout for debugging
console.log(` ✅ ${path.basename(outPath)} — ${lines.length} lines:`);
lines.forEach(l => console.log(` "${l}"`));
}
// Find input files (supports multiple naming conventions)
function findSlideFile(dir, num) {
const candidates = [
`slide${num}_raw.png`,
`slide_${num}.png`,
`slide${num}.png`,
`raw_${num}.png`,
`${num}.png`
];
for (const name of candidates) {
const p = path.join(dir, name);
if (fs.existsSync(p)) return p;
}
return null;
}
function outputName(dir, num, inputName) {
// If input is slide1_raw.png → output slide1.png
// If input is slide_1.png → output final_1.png
if (inputName.includes('_raw')) {
return path.join(dir, inputName.replace('_raw', ''));
}
if (inputName.startsWith('slide_')) {
return path.join(dir, `final_${num}.png`);
}
return path.join(dir, `slide${num}_final.png`);
}
(async () => {
console.log('📝 Adding text overlays...\n');
console.log('Settings:');
console.log(' Font size: 6.5% of image width');
console.log(' Position: centered at ~30% from top');
console.log(' Max width: 75% of image');
console.log(' Style: white fill, black outline\n');
let success = 0;
for (let i = 0; i < 6; i++) {
const num = i + 1;
const inputFile = findSlideFile(inputDir, num);
if (!inputFile) {
console.error(` ❌ Slide ${num}: no input file found in ${inputDir}`);
continue;
}
const outPath = outputName(inputDir, num, path.basename(inputFile));
await addTextOverlay(inputFile, texts[i], outPath);
success++;
}
console.log(`\n✨ ${success}/6 overlays complete!`);
if (success < 6) process.exit(1);
})();
#!/usr/bin/env node
/**
* Analytics Checker — Upload-Post API
*
* Pulls platform analytics and upload history to track post performance.
*
* How it works:
* 1. Fetches platform-level analytics (followers, impressions, reach) from Upload-Post
* 2. Fetches upload history filtered by profile to get per-post results
* 3. Tracks views, likes, comments, shares per platform
* 4. No manual video-ID linking needed — Upload-Post tracks by request_id automatically
*
* Usage: node check-analytics.js --config <config.json> [--days 3] [--profile upload_post]
*
* --days: How many days back to check upload history (default: 3)
* --profile: Override profile from config
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const days = parseInt(getArg('days') || '3');
const profileOverride = getArg('profile');
if (!configPath) {
console.error('Usage: node check-analytics.js --config <config.json> [--days 3] [--profile name]');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const BASE_URL = 'https://api.upload-post.com/api';
const API_KEY = config.uploadPost?.apiKey;
const PROFILE = profileOverride || config.uploadPost?.profile || 'upload_post';
const PLATFORMS = config.uploadPost?.platforms || ['tiktok', 'instagram'];
if (!API_KEY) {
console.error('❌ Missing uploadPost.apiKey in config');
process.exit(1);
}
async function api(method, endpoint) {
const res = await fetch(`${BASE_URL}${endpoint}`, {
method,
headers: { 'Authorization': `Apikey ${API_KEY}` }
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${method} ${endpoint} failed (${res.status}): ${text.substring(0, 200)}`);
}
return res.json();
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
(async () => {
const now = new Date();
const startDate = new Date(now - days * 86400000);
console.log(`📊 Checking analytics (last ${days} days)\n`);
console.log(` Profile: ${PROFILE}`);
console.log(` Platforms: ${PLATFORMS.join(', ')}\n`);
// ==========================================
// 1. Platform-level analytics
// ==========================================
console.log('📈 Platform Analytics:\n');
try {
const platformsParam = PLATFORMS.join(',');
const analytics = await api('GET', `/analytics/${PROFILE}?platforms=${platformsParam}`);
for (const platform of PLATFORMS) {
const data = analytics[platform];
if (!data) {
console.log(` ${platform}: No data available`);
continue;
}
if (data.error || data.message) {
console.log(` ${platform}: ${data.error || data.message}`);
continue;
}
console.log(` 📱 ${platform.toUpperCase()}:`);
if (data.followers !== undefined) console.log(` Followers: ${data.followers.toLocaleString()}`);
if (data.impressions !== undefined) console.log(` Impressions: ${data.impressions.toLocaleString()}`);
if (data.reach !== undefined) console.log(` Reach: ${data.reach.toLocaleString()}`);
if (data.profileViews !== undefined) console.log(` Profile Views: ${data.profileViews.toLocaleString()}`);
// Show recent reach trend from timeseries
if (data.reach_timeseries && data.reach_timeseries.length > 0) {
const recent = data.reach_timeseries.slice(-7);
const totalReach = recent.reduce((sum, d) => sum + (d.value || 0), 0);
console.log(` Reach (last 7 days): ${totalReach.toLocaleString()}`);
}
console.log('');
}
} catch (err) {
console.log(` ⚠️ Analytics error: ${err.message}\n`);
}
// ==========================================
// 2. Upload history (per-post tracking)
// ==========================================
console.log('📋 Upload History (last ' + days + ' days):\n');
const allUploads = [];
let page = 1;
const maxPages = 10;
try {
// Fetch upload history pages until we go past our date range
while (page <= maxPages) {
const history = await api('GET', `/uploadposts/history?page=${page}&limit=50&profile_username=${PROFILE}`);
const items = history.history || [];
if (items.length === 0) break;
for (const item of items) {
const uploadDate = new Date(item.upload_timestamp);
if (uploadDate < startDate) {
// We've gone past our date range
page = maxPages + 1; // break outer loop
break;
}
allUploads.push(item);
}
if (items.length < 50) break; // last page
page++;
await sleep(300);
}
} catch (err) {
console.log(` ⚠️ History error: ${err.message}\n`);
}
// Filter to our target platforms
const relevantUploads = allUploads.filter(u =>
PLATFORMS.includes(u.platform?.toLowerCase())
);
// Group by request_id (a single post can have multiple platform entries)
const postsByRequestId = {};
for (const upload of relevantUploads) {
const rid = upload.request_id || 'unknown';
if (!postsByRequestId[rid]) {
postsByRequestId[rid] = {
requestId: rid,
caption: upload.post_title || upload.post_caption || '',
uploadDate: upload.upload_timestamp,
platforms: {},
success: true
};
}
postsByRequestId[rid].platforms[upload.platform] = {
success: upload.success,
postUrl: upload.post_url || null,
postId: upload.platform_post_id || null,
error: upload.error_message || null,
mediaType: upload.media_type
};
if (!upload.success) postsByRequestId[rid].success = false;
}
const posts = Object.values(postsByRequestId);
posts.sort((a, b) => new Date(b.uploadDate) - new Date(a.uploadDate));
console.log(` Found ${posts.length} posts across ${relevantUploads.length} platform uploads\n`);
const results = [];
for (const post of posts) {
const date = post.uploadDate?.slice(0, 10) || 'unknown';
const statusIcon = post.success ? '✅' : '❌';
const platformList = Object.entries(post.platforms)
.map(([p, info]) => `${p}${info.success ? '✅' : '❌'}`)
.join(', ');
console.log(` ${statusIcon} ${date} | ${post.caption.substring(0, 55)}...`);
console.log(` Platforms: ${platformList}`);
console.log(` Request ID: ${post.requestId}`);
// Show post URLs
for (const [platform, info] of Object.entries(post.platforms)) {
if (info.postUrl) {
console.log(` ${platform}: ${info.postUrl}`);
}
if (info.error) {
console.log(` ${platform} error: ${info.error}`);
}
}
console.log('');
results.push({
requestId: post.requestId,
date,
caption: post.caption.substring(0, 70),
platforms: post.platforms,
success: post.success
});
}
// ==========================================
// 3. Save analytics snapshot
// ==========================================
const baseDir = path.dirname(configPath);
const analyticsPath = path.join(baseDir, 'analytics-snapshot.json');
const snapshot = {
date: now.toISOString(),
profile: PROFILE,
platforms: PLATFORMS,
posts: results,
totalPosts: results.length,
successfulPosts: results.filter(r => r.success).length,
failedPosts: results.filter(r => !r.success).length
};
fs.writeFileSync(analyticsPath, JSON.stringify(snapshot, null, 2));
console.log(`💾 Saved analytics snapshot to ${analyticsPath}`);
// ==========================================
// 4. Summary
// ==========================================
console.log('\n📊 Summary:');
console.log(` Posts tracked: ${results.length}`);
console.log(` Successful: ${snapshot.successfulPosts}`);
console.log(` Failed: ${snapshot.failedPosts}`);
// Per-platform summary
const platformCounts = {};
for (const post of results) {
for (const [platform, info] of Object.entries(post.platforms)) {
if (!platformCounts[platform]) platformCounts[platform] = { success: 0, failed: 0 };
if (info.success) platformCounts[platform].success++;
else platformCounts[platform].failed++;
}
}
for (const [platform, counts] of Object.entries(platformCounts)) {
console.log(` ${platform}: ${counts.success} ok, ${counts.failed} failed`);
}
})();
#!/usr/bin/env node
/**
* Competitor Research — Save & Query Findings
*
* The actual research is done by the agent using the browser.
* This script manages the competitor-research.json file.
*
* Usage:
* node competitor-research.js --dir tiktok-marketing/ --summary
* node competitor-research.js --dir tiktok-marketing/ --add-competitor '{"name":"AppX","tiktokHandle":"@appx",...}'
* node competitor-research.js --dir tiktok-marketing/ --gaps
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
const filePath = path.join(dir, 'competitor-research.json');
function loadData() {
if (!fs.existsSync(filePath)) {
return {
researchDate: '',
competitors: [],
nicheInsights: { trendingSounds: [], commonFormats: [], gapOpportunities: '', avoidPatterns: '' }
};
}
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
function saveData(data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
if (args.includes('--summary')) {
const data = loadData();
if (data.competitors.length === 0) {
console.log('No competitor research yet. Use the browser to research competitors first.');
process.exit(0);
}
console.log(`📊 Competitor Research (${data.researchDate})\n`);
console.log(`Found ${data.competitors.length} competitors:\n`);
data.competitors.forEach(c => {
console.log(` ${c.name} (${c.tiktokHandle || 'no handle'})`);
console.log(` Followers: ${c.followers || '?'} | Avg views: ${c.avgViews || '?'}`);
if (c.bestVideo) console.log(` Best: ${c.bestVideo.views} views — "${c.bestVideo.hook}"`);
if (c.strengths) console.log(` Strengths: ${c.strengths}`);
if (c.weaknesses) console.log(` Weaknesses: ${c.weaknesses}`);
console.log('');
});
if (data.nicheInsights?.gapOpportunities) {
console.log(`💡 Gap opportunities: ${data.nicheInsights.gapOpportunities}`);
}
if (data.nicheInsights?.avoidPatterns) {
console.log(`⚠️ Avoid: ${data.nicheInsights.avoidPatterns}`);
}
}
if (args.includes('--add-competitor')) {
const idx = args.indexOf('--add-competitor');
const json = args[idx + 1];
try {
const competitor = JSON.parse(json);
const data = loadData();
data.competitors.push(competitor);
data.researchDate = new Date().toISOString().split('T')[0];
saveData(data);
console.log(`✅ Added competitor: ${competitor.name}`);
} catch (e) {
console.error('Invalid JSON for competitor:', e.message);
process.exit(1);
}
}
if (args.includes('--gaps')) {
const data = loadData();
if (!data.nicheInsights) {
console.log('No niche insights yet.');
process.exit(0);
}
console.log('Gap Analysis:\n');
console.log(` Opportunities: ${data.nicheInsights.gapOpportunities || 'None recorded'}`);
console.log(` Avoid: ${data.nicheInsights.avoidPatterns || 'None recorded'}`);
console.log(` Common formats: ${(data.nicheInsights.commonFormats || []).join(', ') || 'None recorded'}`);
console.log(` Trending sounds: ${(data.nicheInsights.trendingSounds || []).join(', ') || 'None recorded'}`);
}
#!/usr/bin/env node
/**
* Generate 6 TikTok slideshow images using the user's chosen image generation provider.
*
* Supported providers:
* - openai (gpt-image-1.5 STRONGLY RECOMMENDED — never use gpt-image-1)
* - stability (Stable Diffusion via Stability AI API)
* - replicate (any model via Replicate API)
* - local (user provides pre-made images, skips generation)
*
* Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>
*
* prompts.json format:
* {
* "base": "Shared base prompt for all slides",
* "slides": ["Slide 1 additions", "Slide 2 additions", ...6 total]
* }
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const outputDir = getArg('output');
const promptsPath = getArg('prompts');
if (!configPath || !outputDir || !promptsPath) {
console.error('Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const prompts = JSON.parse(fs.readFileSync(promptsPath, 'utf-8'));
if (!prompts.slides || prompts.slides.length !== 6) {
console.error('ERROR: prompts.json must have exactly 6 slides');
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
const provider = config.imageGen?.provider || 'openai';
const model = config.imageGen?.model || 'gpt-image-1.5';
const apiKey = config.imageGen?.apiKey;
if (!apiKey && provider !== 'local') {
console.error(`ERROR: No API key found in config.imageGen.apiKey for provider "${provider}"`);
process.exit(1);
}
// Warn if using gpt-image-1 instead of 1.5
if (provider === 'openai' && model && !model.includes('1.5')) {
console.warn(`\n⚠️ WARNING: You're using "${model}" — this produces noticeably AI-looking images.`);
console.warn(` STRONGLY RECOMMENDED: Switch to "gpt-image-1.5" in your config for photorealistic results.`);
console.warn(` The quality difference is massive and directly impacts views.\n`);
}
// ─── Provider: OpenAI ───────────────────────────────────────────────
async function generateOpenAI(prompt, outPath) {
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
prompt,
n: 1,
size: '1024x1536',
quality: 'high'
}),
signal: global.__abortSignal
});
const data = await res.json();
if (data.error) throw new Error(data.error.message);
fs.writeFileSync(outPath, Buffer.from(data.data[0].b64_json, 'base64'));
}
// ─── Provider: Stability AI ─────────────────────────────────────────
async function generateStability(prompt, outPath) {
const engineId = model || 'stable-diffusion-xl-1024-v1-0';
const res = await fetch(`https://api.stability.ai/v1/generation/${engineId}/text-to-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
text_prompts: [{ text: prompt, weight: 1 }],
cfg_scale: 7,
height: 1536,
width: 1024,
steps: 30,
samples: 1
})
});
const data = await res.json();
if (data.message) throw new Error(data.message);
fs.writeFileSync(outPath, Buffer.from(data.artifacts[0].base64, 'base64'));
}
// ─── Provider: Replicate ────────────────────────────────────────────
async function generateReplicate(prompt, outPath) {
const replicateModel = model || 'black-forest-labs/flux-1.1-pro';
// Create prediction
const createRes = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
'Authorization': `Token ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: replicateModel,
input: {
prompt,
width: 1024,
height: 1536,
num_outputs: 1
}
})
});
let prediction = await createRes.json();
if (prediction.error) throw new Error(prediction.error.detail || prediction.error);
// Poll for completion
while (prediction.status !== 'succeeded' && prediction.status !== 'failed') {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(prediction.urls.get, {
headers: { 'Authorization': `Token ${apiKey}` }
});
prediction = await pollRes.json();
}
if (prediction.status === 'failed') throw new Error(prediction.error || 'Prediction failed');
// Download image
const imageUrl = Array.isArray(prediction.output) ? prediction.output[0] : prediction.output;
const imgRes = await fetch(imageUrl);
const buf = Buffer.from(await imgRes.arrayBuffer());
fs.writeFileSync(outPath, buf);
}
// ─── Provider: Local (skip generation) ──────────────────────────────
async function generateLocal(prompt, outPath) {
const slideNum = path.basename(outPath).match(/\d+/)?.[0];
const localPath = path.join(outputDir, `local_slide${slideNum}.png`);
if (fs.existsSync(localPath)) {
fs.copyFileSync(localPath, outPath);
} else {
throw new Error(`Place your image at ${localPath} — local provider skips generation`);
}
}
// ─── Retry with timeout ─────────────────────────────────────────────
async function withRetry(fn, retries = 2, timeoutMs = 120000) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Pass abort signal via global (providers use fetch which supports it)
global.__abortSignal = controller.signal;
const result = await fn();
clearTimeout(timer);
return result;
} catch (e) {
if (attempt < retries) {
const isTimeout = e.name === 'AbortError' || e.message?.includes('timeout') || e.message?.includes('abort');
console.log(` ⚠️ ${isTimeout ? 'Timeout' : 'Error'}: ${e.message}. Retrying (${attempt + 1}/${retries})...`);
await new Promise(r => setTimeout(r, 3000 * (attempt + 1)));
} else {
throw e;
}
}
}
}
// ─── Router ─────────────────────────────────────────────────────────
const providers = {
openai: generateOpenAI,
stability: generateStability,
replicate: generateReplicate,
local: generateLocal
};
async function generate(prompt, outPath) {
const fn = providers[provider];
if (!fn) {
console.error(`Unknown provider: "${provider}". Supported: ${Object.keys(providers).join(', ')}`);
process.exit(1);
}
console.log(` Generating ${path.basename(outPath)} [${provider}/${model}]...`);
await withRetry(() => fn(prompt, outPath));
console.log(` ✅ ${path.basename(outPath)}`);
}
(async () => {
console.log(`🎬 Generating 6 slides for ${config.app?.name || 'app'} using ${provider}/${model}\n`);
let success = 0;
let skipped = 0;
for (let i = 0; i < 6; i++) {
const outPath = path.join(outputDir, `slide${i + 1}_raw.png`);
// Skip if already exists (resume from partial run)
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 10000) {
console.log(` ⏭ slide${i + 1}_raw.png already exists, skipping`);
success++;
skipped++;
continue;
}
const fullPrompt = `${prompts.base}\n\n${prompts.slides[i]}`;
try {
await generate(fullPrompt, outPath);
success++;
} catch (e) {
console.error(` ❌ Slide ${i + 1} failed after retries: ${e.message}`);
console.error(` Re-run this script to retry — completed slides will be skipped.`);
}
}
console.log(`\n✨ Generated ${success}/6 slides in ${outputDir}${skipped > 0 ? ` (${skipped} skipped — already existed)` : ''}`);
if (success < 6) {
console.error(`\n⚠️ ${6 - success} slides failed. Re-run to retry — completed slides are preserved.`);
process.exit(1);
}
})();
#!/usr/bin/env node
/**
* TikTok App Marketing — Onboarding Config Validator
*
* The onboarding is CONVERSATIONAL — the agent talks to the user naturally,
* not through this script. This script validates the resulting config is complete.
*
* Usage:
* node onboarding.js --validate --config tiktok-marketing/config.json
* node onboarding.js --init --dir tiktok-marketing/
*
* --validate: Check config completeness, show what's missing
* --init: Create the directory structure and empty config files
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const configPath = args.includes('--config') ? args[args.indexOf('--config') + 1] : null;
const validate = args.includes('--validate');
const init = args.includes('--init');
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
if (init) {
// Create directory structure
const dirs = [dir, `${dir}/posts`, `${dir}/hooks`, `${dir}/reports`];
dirs.forEach(d => {
if (!fs.existsSync(d)) {
fs.mkdirSync(d, { recursive: true });
console.log(`📁 Created ${d}/`);
}
});
// Empty config template — using Upload-Post instead of Postiz
const configTemplate = {
app: {
name: '',
description: '',
audience: '',
problem: '',
differentiator: '',
appStoreUrl: '',
category: '',
isMobileApp: false
},
imageGen: {
provider: '',
apiKey: '',
model: ''
},
uploadPost: {
apiKey: '',
profile: 'upload_post',
platforms: ['tiktok', 'instagram']
},
revenuecat: {
enabled: false,
v2SecretKey: '',
projectId: ''
},
posting: {
schedule: ['07:30', '16:30', '21:00'],
crossPost: []
},
competitors: `${dir}/competitor-research.json`,
strategy: `${dir}/strategy.json`
};
const cfgPath = `${dir}/config.json`;
if (!fs.existsSync(cfgPath)) {
fs.writeFileSync(cfgPath, JSON.stringify(configTemplate, null, 2));
console.log(`📝 Created ${cfgPath}`);
}
// Empty competitor research template
const compPath = `${dir}/competitor-research.json`;
if (!fs.existsSync(compPath)) {
fs.writeFileSync(compPath, JSON.stringify({
researchDate: '',
competitors: [],
nicheInsights: {
trendingSounds: [],
commonFormats: [],
gapOpportunities: '',
avoidPatterns: ''
}
}, null, 2));
console.log(`📝 Created ${compPath}`);
}
// Empty strategy template
const stratPath = `${dir}/strategy.json`;
if (!fs.existsSync(stratPath)) {
fs.writeFileSync(stratPath, JSON.stringify({
hooks: [],
postingSchedule: ['07:30', '16:30', '21:00'],
hookCategories: { testing: [], proven: [], dropped: [] },
crossPostPlatforms: [],
notes: ''
}, null, 2));
console.log(`📝 Created ${stratPath}`);
}
// Empty hook performance tracker
const hookPath = `${dir}/hook-performance.json`;
if (!fs.existsSync(hookPath)) {
fs.writeFileSync(hookPath, JSON.stringify({
hooks: [],
ctas: [],
rules: { doubleDown: [], testing: [], dropped: [] }
}, null, 2));
console.log(`📝 Created ${hookPath}`);
}
console.log('\n✅ Directory structure ready. Start the conversational onboarding to fill in config.');
process.exit(0);
}
if (validate && configPath) {
if (!fs.existsSync(configPath)) {
console.error(`❌ Config not found: ${configPath}`);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const required = [];
const optional = [];
// App profile (required)
if (!config.app?.name) required.push('app.name — What is the app called?');
if (!config.app?.description) required.push('app.description — What does it do?');
if (!config.app?.audience) required.push('app.audience — Who is it for?');
if (!config.app?.problem) required.push('app.problem — What problem does it solve?');
if (!config.app?.category) required.push('app.category — What category?');
// Image generation (required)
if (!config.imageGen?.provider) required.push('imageGen.provider — Which image tool?');
if (config.imageGen?.provider && config.imageGen.provider !== 'local' && !config.imageGen?.apiKey) {
required.push('imageGen.apiKey — API key for image generation');
}
// Upload-Post (required)
if (!config.uploadPost?.apiKey) required.push('uploadPost.apiKey — Upload-Post API key');
if (!config.uploadPost?.profile) required.push('uploadPost.profile — Upload-Post profile name');
if (!config.uploadPost?.platforms || config.uploadPost.platforms.length === 0) {
required.push('uploadPost.platforms — At least one platform (tiktok, instagram)');
}
// Competitor research (important but not blocking)
const compPath = config.competitors;
if (compPath && fs.existsSync(compPath)) {
const comp = JSON.parse(fs.readFileSync(compPath, 'utf-8'));
if (!comp.competitors || comp.competitors.length === 0) {
optional.push('Competitor research — no competitors analyzed yet (run browser research)');
}
} else {
optional.push('Competitor research — file not created yet');
}
// Strategy
const stratPath = config.strategy;
if (stratPath && fs.existsSync(stratPath)) {
const strat = JSON.parse(fs.readFileSync(stratPath, 'utf-8'));
if (!strat.hooks || strat.hooks.length === 0) {
optional.push('Content strategy — no hooks planned yet');
}
} else {
optional.push('Content strategy — file not created yet');
}
// RevenueCat (optional)
if (config.app?.isMobileApp && !config.revenuecat?.enabled) {
optional.push('RevenueCat — mobile app detected but RC not connected (recommended for conversion tracking)');
}
// App Store link
if (!config.app?.appStoreUrl) optional.push('App Store / website URL — helpful for competitor research');
// Results
if (required.length === 0) {
console.log('✅ Core config complete! Ready to start posting.\n');
} else {
console.log('❌ Missing required config:\n');
required.forEach(r => console.log(` ⬚ ${r}`));
console.log('');
}
if (optional.length > 0) {
console.log('💡 Recommended (not blocking):\n');
optional.forEach(o => console.log(` ○ ${o}`));
console.log('');
}
// Summary
console.log('📋 Setup Summary:');
console.log(` App: ${config.app?.name || '(not set)'}`);
console.log(` Category: ${config.app?.category || '(not set)'}`);
console.log(` Image Gen: ${config.imageGen?.provider || '(not set)'}${config.imageGen?.model ? ` (${config.imageGen.model})` : ''}`);
console.log(` Upload-Post Profile: ${config.uploadPost?.profile || '(not set)'}`);
console.log(` Platforms: ${(config.uploadPost?.platforms || []).join(', ') || '(none)'}`);
if (config.revenuecat?.enabled) console.log(` RevenueCat: Connected`);
console.log(` Schedule: ${(config.posting?.schedule || []).join(', ')}`);
process.exit(required.length > 0 ? 1 : 0);
} else {
console.log('Usage:');
console.log(' node onboarding.js --init --dir tiktok-marketing/ Create directory structure');
console.log(' node onboarding.js --validate --config config.json Validate config completeness');
}