The 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.
AccessWhy it is neededIf you declineLogged-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 surfaceSkill is unusable — no fallbackmacOS Accessibility permission on the cron's terminal ( cliclick)TikTok's composer reads React controlled state and ignores synthetic events; only real clicks/keystrokes reach itSkill is unusable. This permission lets that terminal click and type anywhere on your machine, not just in TikTok. Use a dedicated low-privilege user sessionClipboard ( pbcopy + ⌘V)Only reliable path for multi-line DM bodiesUse the typing path (§ Sending), ASCII onlyScreenshots / DOM snapshots to a scratch dirPost-mortem when a reply lands in the wrong boxSkip — you lose debuggability, nothing elseAlert webhook (Telegram/Slack/Discord)Run recaps and blocker alertsLeave 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.
FileHoldsRetentiontiktok-recaps.md · tiktok-reply-log.mdRun recaps · username + type + timestamp only — never DM bodies****Prune at 90 daystiktok-alerts-sent.mdA hash of the username, not the handle — dedupes lead alerts without keeping whoPrune at 90 daystiktok-post-log.md · tiktok-state.md · tiktok-hashtag-state.md · tiktok-sound-state.md · tiktok-ideas.md · tiktok-learnings.mdYour own posts, phase, warnings, follower count, per-hashtag/sound counters, content ideas, causes of removalsCounters and your own content only — no third-party identifiers
You sayThe 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 appearedStop. 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
PlaceholderExample<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"yaml
# <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:
bash
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)
RoleIntentDefault pageWrites to TikTok?tk-postAct as the account: reply to inbound DMs and comments/business-suite/messagesYestk-engageDiscover and qualify what deserves a reply/search?q=<NICHE_KEYWORDS>&t=videoNotk-observeRead-only. Look at the FYP, peer profiles, sounds. Nothing is sent, nothing is written, no account state changes/foryouNo
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.
bash
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.
PhaseConditionAuthorizedAAccount < 30 days OR a Community Guidelines notice in the last 14 days OR < 500 followersPost videos; reply to inbound DMs only, no brand mention. Nothing elseB≥ 30 days, no notice for 14 days, ≥ 500 followersFull 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.
ActionLimitDM replies / 24 h · / run30 · 4Comment replies / 24 h · / run40 · 6Outbound 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 policyFollow / day20 (Phase B) · 5 (Phase A) — TikTok blocks the action for 24 h past ~25/dayUnfollow / day20Two actions in the same video thread≥ 30 min apartAny 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.
CheckFails whenActionReal messageIt is a share, forward, or a language you cannot answer nativelySkipGenuine intent in your domainA vent, a meme, a request for free expert workSkipNot a repeatA templated reply went to the same user < 7 days ago ( tiktok-reply-log.md)SkipPhaseReply mentions the brand while in Phase AReply without the mention, or skipFull context read firstThe whole DM thread ( [data-e2e="chat-item"]), or caption + comment thread, was not readRead 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:
text
[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:
FileOne entry perFieldstiktok-hashtag-state.mdHashtagLast 5 videos that used it + reach (views, watch-time %) · banned/sensitive flag · manual override listtiktok-sound-state.mdSoundOriginal 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:
MethodWhy it failscliclick kd:cmd t:v ku:cmdTypes the literal letter "v" — the cmd keydown is released before t:v fires. Use osascript for ⌘Vcliclick kp:return to sendThe composer ignores the Enter key in DMs and comments. You must click the send buttonexecCommand('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:
text
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.
python
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.
js
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
CodeMeaningRecap0Sent and verifiedstatus: ok, log the reply1Fatal (ref missing, iframe absent, send never enabled)status: error + screenshot2Soft block / "Tap to retry"status: blocked, stop, alert3Session / login KOstatus: blocked, alert to re-login4Landed top-level instead of nestedstatus: 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.
SignalActionCaptcha / puzzle challengeStop 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 toHTTP 429 / rate limitedStop. Wait for the next scheduled run. Do not shorten the interval, do not retry"Tap to retry" toastSoft 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-enablingBanner: "your account is at risk of restriction"Phase A immediately. All reply crons off until a human reviewsComment removed by TikTok < 10 min after publishAuto-freeze the comment cron 6 h. Append the cause to tiktok-learnings.mdComment landed top-levelFlag for human cleanup. Pause comment cron 30 minAccount banned or shadowbannedStop everything. Do not auto-appeal — automated appeals are themselves abuse. Human, manual, once
IssueCauseFixReply posted as a top-level commentTwo textboxes exist after clicking "Reply"; you took the general oneTake the smallest vpY (§ Sending), re-verify against the parent's childrenAccents vanished or turned to garbage in a sent commentcliclick t:'…' silently drops/substitutes non-ASCII in some locales ( fr_FR.UTF-8). No error is raised — the script looks correct, the reply looks illiterateStrip accents and emoji before typing: é→e, '→', …→...Paste landed somewhere else entirelyiframe focus drift — a JS click inside an iframe does not always focus the inner documentA physical cliclick after every navigation, before every paste or typeevaluate returns null, no errorThe tab is off tiktok.comNavigate first, retry onceCron dies mid-send, no errorDefault job timeout — a 120 s default kills a flow that was workingPosting crons need ≥ 1200 sSelectors that worked yesterday return nullTikTok shipped a UI changeSnapshot 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#text
## 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: