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:
- Recognises it as a credential problem, not a problem with the video, and does not spend one of the video's retry attempts — so a good video isn't parked because your token lapsed.
- Remembers a SHA-256 fingerprint of the rejected token in blob storage, so later runs fail immediately without calling TikTok at all.
- Emails you once, then re-alerts at most every
AUTH_ALERT_REPEAT_HOURS(default 24) instead of twice a day. - Clears all of that automatically the moment a working token appears — just
paste a new
TIKTOK_ACCESS_TOKENand 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 untilattemptsreachesMAX_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.
- The Opus Clip endpoint is not a documented public API.
POST https://api.opus.pro/v1/clipsfollows the commonly-referenced contract, but Opus Clip does not publish a stable spec.lib/opus.tsis 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 adjustcreateBody()and the*_KEYSlists. Everything else in the pipeline stays the same. - TikTok
PULL_FROM_URLrequires 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_EVERYONErequires an audited app. Until your TikTok app passes audit, TikTok forcesSELF_ONLYand rejects public posts.- YouTube quota.
search.listcosts 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. - Opus Clip renders can outlast a run. Polling gives up after 5 minutes; if the render is slower, that run records a failure and the next one retries.
- Run scripts/send-test-email.ts to confirm email delivery works.
- Run scripts/smoke-test.ts to check the SQLite layer, the retry bookkeeping and the cleanup. It leaves the database untouched.
- Run main.ts manually from the editor to exercise the full pipeline without waiting for the cron.