A scheduled Val Town job that runs every 12 hours (0 */12 * * *, UTC) and
automates the whole short-form content loop: find a trending YouTube video,
have AI cut it into a vertical clip, publish that clip straight to TikTok, and
email you the result.
Rendering mermaid diagram...
Every step is wrapped in its own try/catch. A failure logs to the console,
records the attempt, emails you, and returns cleanly — so the next scheduled
run still fires.
| File | Role |
|---|---|
main.ts | The interval handler + pipeline orchestration |
lib/config.ts | Tunables: MAX_ATTEMPTS, AUTH_ALERT_REPEAT_HOURS, SEARCH_QUERIES |
lib/env.ts | Env var names, required-key preflight |
lib/db.ts | SQLite schema, migrations, retry bookkeeping |
lib/youtube.ts | Step 1 — trending video discovery |
lib/opus.ts | Step 2 — AI clipping |
lib/tiktok.ts | Step 3 — token handling + direct publishing |
lib/notify.ts | Email notifications (never throws) |
scripts/smoke-test.ts | Exercises the SQLite layer; safe to run anytime |
scripts/send-test-email.ts | Sends one test email; safe to run anytime |
| Key | Required? | Where to get it |
|---|---|---|
YOUTUBE_API_KEY | yes | Google Cloud Console → enable YouTube Data API v3 → API key |
OPUS_CLIP_API_KEY | yes | Opus Clip account / API access |
TIKTOK_ACCESS_TOKEN | yes¹ | TikTok for Developers → Content Posting API (scope video.publish) |
NOTIFY_EMAIL | no | Where to send notifications. Omit it to mail the val owner's own address. |
TIKTOK_CLIENT_KEY | optional² | Your TikTok app's client key |
TIKTOK_CLIENT_SECRET | optional² | Your TikTok app's client secret |
TIKTOK_REFRESH_TOKEN | optional² | From the OAuth flow — the seed for automatic renewal |
¹ Or use the refresh triple instead — see below. ² Only needed if you want the val to renew its own TikTok token.
The job skips itself and emails you if anything required is missing.
If you only have an access token — no refresh_token — that's fully supported.
Set TIKTOK_ACCESS_TOKEN and leave the three TIKTOK_CLIENT_* /
TIKTOK_REFRESH_TOKEN keys unset.
TikTok access tokens expire in ~24 hours, which is shorter than the gap between runs, so a token will die between runs. When that happens the val:
AUTH_ALERT_REPEAT_HOURS
(default 24) instead of twice a day.TIKTOK_ACCESS_TOKEN and the next run resumes.If you do get a refresh token later, set the three TIKTOK_CLIENT_KEY /
TIKTOK_CLIENT_SECRET / TIKTOK_REFRESH_TOKEN keys and the val renews itself
indefinitely. TikTok rotates the refresh token on every exchange, so the new
pair is cached in blob storage and the env var is only the initial seed.
The table tracks how each video went, so one bad video can't block the pipeline forever:
CREATE TABLE IF NOT EXISTS processed_trending_videos (
video_id TEXT PRIMARY KEY,
processed_at DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- 'posted' | 'failed'
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
status = 'posted' → never retried.status = 'failed' → retried until attempts reaches MAX_ATTEMPTS
(default 3, so ≈36 hours of retrying at one run per 12h), then skipped.The first two columns are the original schema; the other three are added by an
idempotent ALTER TABLE migration on startup, so an existing table upgrades
itself.
Tune the budget in lib/config.ts. To give a parked video another chance, call
forgetProcessed(videoId) — or just delete the row.
These are real constraints of the third-party APIs, not bugs in this val.
POST https://api.opus.pro/v1/clips follows the commonly-referenced contract, but
Opus Clip does not publish a stable spec. lib/opus.ts is written to be
forgiving — it walks the response for the first plausible clip URL, caption
and hashtags, and polls for up to 5 minutes if the render is async — but if
your account's contract differs you may need to adjust createBody() and the
*_KEYS lists. Everything else in the pipeline stays the same.PULL_FROM_URL requires a verified domain. TikTok fetches the
clip from the URL Opus returns, so that host must be registered as a verified
URL property in your TikTok developer app. If it isn't, the post is rejected.PUBLIC_TO_EVERYONE requires an audited app. Until your TikTok app
passes audit, TikTok forces SELF_ONLY and rejects public posts.search.list costs 100 units; the default daily quota is
10,000. With 4 queries × 2 runs/day that's 800 units/day — comfortable, but
don't add dozens of keywords.