Install
openclaw skills install @alexbloch-ia/tiktok-account-operationsRun a TikTok Business account from cron — inbound DM/comment replies, quotas, phase gates, stop-on-block. Use when scheduling TikTok replies. Trigger on "tiktok dm", "tiktok reply".
openclaw skills install @alexbloch-ia/tiktok-account-operationsThe goal is not to reply fast. The goal is to operate one account you own with the care of a real contributor — under TikTok's Terms of Service and Community Guidelines, inside its published limits, and stopped the moment a platform control fires.
This skill drives a logged-in browser and your operating system's input layer. That is a large amount of power: grant it deliberately, per item, and revoke it when the account is not being operated. Beyond cliclick, the snippets assume the OpenClaw browser CLI over CDP plus the macOS built-ins pbcopy and osascript.
| Access | Why it is needed | If you decline |
|---|---|---|
Logged-in TikTok browser profile (CDP on 127.0.0.1:<BROWSER_PORT>) | Business Suite has no DM/comment-reply API; the UI is the only surface | Skill is unusable — no fallback |
macOS Accessibility permission on the cron's terminal (cliclick) | TikTok's composer reads React controlled state and ignores synthetic events; only real clicks/keystrokes reach it | Skill is unusable. This permission lets that terminal click and type anywhere on your machine, not just in TikTok. Use a dedicated low-privilege user session |
Clipboard (pbcopy + ⌘V) | Only reliable path for multi-line DM bodies | Use the typing path (§ Sending), ASCII only |
| Screenshots / DOM snapshots to a scratch dir | Post-mortem when a reply lands in the wrong box | Skip — you lose debuggability, nothing else |
| Alert webhook (Telegram/Slack/Discord) | Run recaps and blocker alerts | Leave it unset — this is the default. Recaps then stay local |
Network egress: off by default. The only outbound call this skill makes beyond tiktok.com is the alert webhook, and only if you set alerts.webhook. Recaps sent there contain the account handle, counts, and lead usernames — do not point it at a channel wider than your operators.
Data persisted locally, nowhere else — <WORKSPACE_DIR>/memory/, 9 files. Anything holding a third-party identifier is bounded; nothing here accumulates forever, and you never store personal details a user sent you.
| File | Holds | Retention |
|---|---|---|
tiktok-recaps.md · tiktok-reply-log.md | Run recaps · username + type + timestamp only — never DM bodies | Prune at 90 days |
tiktok-alerts-sent.md | A hash of the username, not the handle — dedupes lead alerts without keeping who | Prune at 90 days |
tiktok-post-log.md · tiktok-state.md · tiktok-hashtag-state.md · tiktok-sound-state.md · tiktok-ideas.md · tiktok-learnings.md | Your own posts, phase, warnings, follower count, per-hashtag/sound counters, content ideas, causes of removals | Counters and your own content only — no third-party identifiers |
| You say | The skill does |
|---|---|
| "check TikTok DMs" | Session check → inbound reply pass, ≤4 per run (§ Sending) |
| "reply to new comments" | Comment pass on /business-suite/comments, ≤6 per run |
| "my reply didn't post" | § Troubleshooting — two-textbox trap first |
| "TikTok blocked my account" / captcha appeared | Stop. Hand to a human. No retry, no workaround (§ Stop conditions) |
| "am I shadowbanned" | Hashtag state check + Phase A flip |
| "DM this list of people" | Out of scope. This skill replies; it does not prospect |
| Placeholder | Example |
|---|---|
<BRAND_NAME> / <TIKTOK_HANDLE> | "Acme Studio" / "@acmestudio" |
<BROWSER_PROFILE> / <BROWSER_PORT> | "tiktok-live" / "18801" |
<PRIMARY_CTA> | one channel only — bio link |
<NICHE_KEYWORDS> | "lost license OR speeding ticket" |
<WORKSPACE_DIR> | "~/.openclaw/workspace/tiktok-acme" |
# <WORKSPACE_DIR>/config.yaml
tiktok: { handle: <TIKTOK_HANDLE>, business_account: true, browser_profile: <BROWSER_PROFILE>, browser_port: <BROWSER_PORT> }
cta: { primary: <PRIMARY_CTA> }
discovery: { niche_keywords: <NICHE_KEYWORDS> }
alerts: { webhook: null } # null = no network egress. Opt in explicitly.
schedule: { timezone: Europe/Paris,
dm_check: "0,15,30,45 9-22 * * *", # every 15 min
comment_check: "3,18,33,48 9-22 * * *", # +3 min — the two must never overlap
browser_health: "0 * * * *", daily_recap: "0 21 * * *" } # recap carries the phase gate — do not drop it
Any CDP stack (Playwright, Puppeteer, Chrome MCP) works — swap the calls. Init the memory files:
mkdir -p "<WORKSPACE_DIR>/memory" && cd "$_" && for f in recaps post-log reply-log state hashtag-state sound-state ideas learnings alerts-sent; do [ -f "tiktok-$f.md" ] || touch "tiktok-$f.md"; done
# Output: (silent, idempotent — existing files are never truncated)
| Role | Intent | Default page | Writes to TikTok? |
|---|---|---|---|
tk-post | Act as the account: reply to inbound DMs and comments | /business-suite/messages | Yes |
tk-engage | Discover and qualify what deserves a reply | /search?q=<NICHE_KEYWORDS>&t=video | No |
tk-observe | Read-only. Look at the FYP, peer profiles, sounds. Nothing is sent, nothing is written, no account state changes | /foryou | No |
tk-observe only reads — that is its definition, not a precaution. A workflow under it that wants to send, follow, or like is in the wrong role; move it to tk-post and spend its quota. After every run, return to the role's default page.
openclaw browser --browser-profile <BROWSER_PROFILE> status
# Output: {"state":"running","port":18801}
openclaw browser --browser-profile <BROWSER_PROFILE> evaluate --fn "async () => { const r = await fetch('https://www.tiktok.com/api/user/detail/self/', {credentials:'include'}); const d = await r.json(); return {ok: d.statusCode === 0, uniqueId: d.userInfo?.user?.uniqueId || null}; }"
# Output: {"ok":true,"uniqueId":"acmestudio"}
state: stopped, ok:false, or uniqueId:null → report the blocker and exit. Never relaunch Chrome from inside a cron; that is a user-side action. Then navigate to /business-suite/messages: a redirect to the public feed or /login means the Business session is expired even when the API call succeeded.
| Phase | Condition | Authorized |
|---|---|---|
| A | Account < 30 days OR a Community Guidelines notice in the last 14 days OR < 500 followers | Post videos; reply to inbound DMs only, no brand mention. Nothing else |
| B | ≥ 30 days, no notice for 14 days, ≥ 500 followers | Full quotas below |
The gate has no manual override — the 30 days are how long TikTok takes to trust the account, and skipping them is the most reliable way to get it restricted. Read the phase from the last line of tiktok-state.md, confirm against /business-suite/posts (a "limited" banner near the post list = Phase A, immediately). The daily_recap cron detects A → B and alerts and stops: flipping the crons on is a human action. First week in B: cap at 2 replies/run.
| Action | Limit |
|---|---|
| DM replies / 24 h · / run | 30 · 4 |
| Comment replies / 24 h · / run | 40 · 6 |
| Outbound first-contact DMs | 0. This skill replies to people who wrote first — it never opens a thread. Unsolicited DM campaigns are out of scope and against TikTok's spam policy |
| Follow / day | 20 (Phase B) · 5 (Phase A) — TikTok blocks the action for 24 h past ~25/day |
| Unfollow / day | 20 |
| Two actions in the same video thread | ≥ 30 min apart |
| Any two actions | ≥ 1 min apart — bursts past ~6 actions/10 min hit a rate limit |
Read tiktok-reply-log.md at the start of every run, count the last 24 h, and abort early when a quota is already met. The two platform thresholds above are quoted so the ceiling is verifiable, not so you can walk up to it: these quotas sit deliberately below them and the account never approaches them.
| Check | Fails when | Action |
|---|---|---|
| Real message | It is a share, forward, or a language you cannot answer natively | Skip |
| Genuine intent in your domain | A vent, a meme, a request for free expert work | Skip |
| Not a repeat | A templated reply went to the same user < 7 days ago (tiktok-reply-log.md) | Skip |
| Phase | Reply mentions the brand while in Phase A | Reply without the mention, or skip |
| Full context read first | The whole DM thread ([data-e2e="chat-item"]), or caption + comment thread, was not read | Read it, or skip — a reply that ignores obvious context is not a useful reply |
Phase A: 1–3 sentences, conversational, no brand, no link, no profession hint, never expert-grade advice (medical, legal, financial). Phase B — pedagogical, not promotional. DM ≤ 600 chars, comment ≤ 200:
[Acknowledge the situation in one neutral sentence.]
[General framework, 2-3 short paragraphs. No statute quote. No price.]
[One next step — <PRIMARY_CTA> only. Never list two channels.]
Filled instance (comment): Six months is the usual window for that kind of request, not two weeks — the delay you're seeing is normal. Happy to point you in the right direction if you want to reach out.
Zero outgoing links in DM or comment bodies — the bio link is the only authorized URL surface (TikTok dampens reach on third-party URLs in user-visible text, captions included), and max one indirect brand mention per reply. Also forbidden: shorteners, phone numbers, emails, "DM me" / "link in bio", guaranteed outcomes, past client cases, payment solicitation, emojis (encoding trap — see § Troubleshooting), identical text across users (spam under TikTok's rules, and the fastest route to a temporary ban).
If asked "are you a bot?" — answer honestly and stop. "Yes, replies on this account are assisted by automation, and a human on the <BRAND_NAME> team reads everything. What can I help with?" Then flag the thread for a human. Never claim to be a person, never give an evasive answer. Someone who asks is entitled to a straight answer, and misleading them protects nothing.
3–5 short videos/week (15–45 s, one idea, hook in 1.5 s) + 1 longer (45–90 s). No burst (3+ in 6 h). 3–5 hashtags: 1 broad + 2–3 niche. Sounds: original or commercially licensed only — a copyrighted track on a Business account is a licensing breach and gets muted on the FYP. Update state at the end of every posting run:
| File | One entry per | Fields |
|---|---|---|
tiktok-hashtag-state.md | Hashtag | Last 5 videos that used it + reach (views, watch-time %) · banned/sensitive flag · manual override list |
tiktok-sound-state.md | Sound | Original or licensed · sound ID / link · reach with that sound vs your baseline |
Shadowban check (this is how you notice a control already fired, per § Stop conditions — not how you get around one): search the hashtag in a private/incognito window. Your video does not appear → flip to Phase A and alert a human.
TikTok's Business Suite composer reads from React controlled state and renders inside iframes. Synthetic events never reach the send pipeline, so real input is a transport requirement, not a disguise: it is how you reach a composer that ignores dispatchEvent. It does nothing to hide the automation, and must not be used to try. What does NOT work — each is the obvious first attempt, and each fails quietly:
| Method | Why it fails |
|---|---|
cliclick kd:cmd t:v ku:cmd | Types the literal letter "v" — the cmd keydown is released before t:v fires. Use osascript for ⌘V |
cliclick kp:return to send | The composer ignores the Enter key in DMs and comments. You must click the send button |
execCommand('insertText') | Visually fills the textbox, never reaches the send pipeline — the send button stays disabled |
Session check → navigate /messages?lang=<lang>, wait ≥ 1.5 s for the redirect → document.querySelectorAll('iframe').length > 0, scope every query to that iframe → list unread [data-e2e="chat-list-item"], click the tile, read all [data-e2e="chat-item"], qualify, draft. Then:
1. pbcopy ← message on the clipboard, UTF-8 2. Page.bringToFront (CDP)
3. screen coords of the textbox (see below)
4. cliclick c:X,Y ← physical click. Non-negotiable: without it the paste lands
in whatever frame holds focus, rarely the textbox
5. sleep 0.5 6. osascript -e 'tell app "System Events" to keystroke "v" using command down'
7. sleep 1 ← the Send button enables asynchronously
8. screen coords of [data-e2e="message-send"] → cliclick c:X,Y 9. verify the textbox is empty
Left pane = video list in the main DOM, badge /(\d+) (nouveau|new)/. Right pane = two iframes: iframe[0] messages (background), iframe[1] comments — always scope to iframe[1]. Click the video tile → wait 2 s → read [data-e2e="comment-level-1"] → qualify → click [data-e2e="comment-reply-1"] → wait 1.5 s. Two textboxes now exist. The inline (nested) one has the smallest vpY; the large one at the bottom posts a new top-level comment instead.
inline_tb = min(textboxes, key=lambda t: t["vpY"]) # nested reply box
post_btn = min([b for b in post_btns if not b["disabled"]],
key=lambda b: abs(b["vpY"] - inline_tb["vpY"]))
Then: cliclick c: on inline_tb → sleep 0.5 → cliclick t:'ascii_only_message' (type, do not paste — the ⌘V path targets page-level focus) → sleep 1 → cliclick c: on post_btn. Re-snapshot and confirm your username appears under the parent comment.
const m = { sX: window.screenX, sY: window.screenY, ih: window.innerHeight, oh: window.outerHeight };
// screenX = sX + vpX
// screenY = sY + (oh - ih) + vpY // (oh - ih) = Chrome top bar, ≈ 87 on default macOS
| Code | Meaning | Recap |
|---|---|---|
| 0 | Sent and verified | status: ok, log the reply |
| 1 | Fatal (ref missing, iframe absent, send never enabled) | status: error + screenshot |
| 2 | Soft block / "Tap to retry" | status: blocked, stop, alert |
| 3 | Session / login KO | status: blocked, alert to re-login |
| 4 | Landed top-level instead of nested | status: partial, flag for manual cleanup |
When TikTok challenges you, you stop. You do not adapt, retry, slow down, or route around it. A control that fires is the platform telling you it does not consent to what is happening; the only correct response is a human looking at why. Anything else turns a doctrine that claims to protect the account into the thing that loses it.
| Signal | Action |
|---|---|
| Captcha / puzzle challenge | Stop the run. Never attempt to solve it, never re-present it, never hand it to a solver. Alert a human, who completes it manually or decides not to |
| HTTP 429 / rate limited | Stop. Wait for the next scheduled run. Do not shorten the interval, do not retry |
| "Tap to retry" toast | Soft block. Pause the role ≥ 1 h. Report |
| "Action blocked" / "we'll let you know when you can do this again" | Flip to Phase A. Disable reply crons. Manual review before re-enabling |
| Banner: "your account is at risk of restriction" | Phase A immediately. All reply crons off until a human reviews |
| Comment removed by TikTok < 10 min after publish | Auto-freeze the comment cron 6 h. Append the cause to tiktok-learnings.md |
| Comment landed top-level | Flag for human cleanup. Pause comment cron 30 min |
| Account banned or shadowbanned | Stop everything. Do not auto-appeal — automated appeals are themselves abuse. Human, manual, once |
| Issue | Cause | Fix |
|---|---|---|
| Reply posted as a top-level comment | Two textboxes exist after clicking "Reply"; you took the general one | Take the smallest vpY (§ Sending), re-verify against the parent's children |
| Accents vanished or turned to garbage in a sent comment | cliclick t:'…' silently drops/substitutes non-ASCII in some locales (fr_FR.UTF-8). No error is raised — the script looks correct, the reply looks illiterate | Strip accents and emoji before typing: é→e, '→', …→... |
| Paste landed somewhere else entirely | iframe focus drift — a JS click inside an iframe does not always focus the inner document | A physical cliclick after every navigation, before every paste or type |
evaluate returns null, no error | The tab is off tiktok.com | Navigate first, retry once |
| Cron dies mid-send, no error | Default job timeout — a 120 s default kills a flow that was working | Posting crons need ≥ 1200 s |
| Selectors that worked yesterday return null | TikTok shipped a UI change | Snapshot manually, read the new data-e2e names, update the selector map, test against your own most recent post, then re-enable the cron |
tiktok-recaps.md and (only if set) the webhook## YYYY-MM-DD HH:MM TZ — <job-id> — status: ok|partial|blocked|skipped
- Phase: A|B
- DMs sent: <N> Comments sent: <N>
- Hot leads: <list or "—">
- Blockers: <text or "—">
- Next useful action: <1 line>
# ── filled instance ──
## 2026-07-16 14:15 CEST — dm-check — status: blocked
- Phase: B
- DMs sent: 1 Comments sent: 0
- Hot leads: @marie_l (asked about the 6-month window)
- Blockers: captcha on 2nd conversation — run stopped, human needed
- Next useful action: solve the challenge manually in the tiktok-live profile, then re-enable dm_check
No action taken → say so and why. Never fake a successful action. Better silence than spam; better a blocker report than a false success.
<PRIMARY_CTA> link.<BROWSER_PORT>, session check returns a non-null uniqueId; memory/ initialized (9 files).cliclick installed; you have read § Access and accept what Accessibility grants that terminal.alerts.webhook left null unless you want recaps leaving the machine.This skill ONLY:
This skill NEVER: