Approach
Scope
Last week a writeup went round documenting TikTok's private Android API: device registration, X-Argus signing, JA3 fingerprints, billions of rows scraped. It was real work and it sells the code for $699. This is the other half of that problem, on iOS: not the network layer but the client itself, instrumented on physical hardware. It is free.
There are two ways to make TikTok believe a phone is watching.
The first is to become the phone. You reverse the Android app, register fake device IDs, rebuild the request signing (X-Argus, X-Ladon, X-Gorgon), match the TLS fingerprint, rotate proxy exit IPs, and speak the private API directly. It scales to billions of rows. It is also an arms race: every constant you copied out of the binary has a shelf life, and TikTok's answer to a mistake is a silent, empty HTTP 200.
The second is to instrument the phone. A physical iPhone, the production TikTok build from the App Store, a real account, driven through the same XCTest input path Apple uses for UI testing. TikTok's client signs its own requests, so there is nothing to reverse and nothing that rots when the app updates. The cost is one device's worth of throughput per device, and every action has to be synthesized as a touch event and verified from a screenshot.
Handler needed the second kind. Our product finds the TikToks that are winning in an app's niche and helps you make your own versions. Making them is half the job. Warming accounts, watching the feed like a person, and posting on a schedule is the other half, and that is what a real-device farm does well.
We built it, ran it in production, and open-sourced the whole application. This page is the technical writeup, layer by layer, from Xcode code signing to the scheduler. The setup guide with the seven install steps is at gethandler.ai/ios-farm.
Emulation versus physical devices
The trade-off, stated plainly. Neither column is a strawman.
| Signed-API emulation | Real iPhone | |
|---|---|---|
| Reads (profiles, videos, comments) | Hundreds per second per proxy pool | One feed at a time |
| Writes (post, like, follow, comment) | Account-gated, mostly out of reach | Every action the app can do |
| Detection surface | Device ID, signature, TLS, region, IP | Behaviour and account history only |
| Maintenance | Re-derive constants on every app change | Re-measure a few tap targets when the UI moves |
| Failure mode | Empty 200, no reason given | A screenshot of what went wrong |
| Cost per unit | Proxy bandwidth | A used iPhone 8 and a USB port |
If you want data, emulate. If you want accounts that post and grow, run phones. We run phones.
The signing stack, observed from the device
Everything the emulation route has to forge, a real iPhone produces for free, because the code that produces it is TikTok's own binary running where it expects to run. This is the same list from the other side.
| What TikTok checks | Emulation has to | On a real iPhone |
|---|---|---|
| Device identity (device_id, iid, install) | Register a plausible fake profile and hope the catalogue is coherent | Real hardware, real install, assigned by TikTok on first launch |
| Activation (app_alert_check and friends) | Discover the undocumented call by trial and error | The app does it itself on first launch |
| Request signing (X-Argus, X-Ladon, X-Gorgon, X-Khronos) | Reimplement Simon, Speck, SM3, AES and the protobuf layout, and re-derive them every release | Computed inside TikTok's own signing library, every request |
| TLS fingerprint (JA3) | Impersonate the Android OkHttp hello with uTLS | Apple's BoringSSL, the fingerprint TikTok expects from an iPhone |
| Regional host selection | Map endpoints to alisg / useast hosts empirically | The app picks its own hosts |
| Exit IP | Rotate proxies, fight keep-alive pinning | The phone's own network |
| Behaviour | Nothing to fake, there is no user | The only thing left to get right, and the only thing this page is about |
The last row is the point. On a physical device the cryptography, the device fingerprint, and the transport are handled by TikTok's binary and Apple's TLS stack. What remains is synthesizing human-shaped input against a UI that never idles, verifying state from pixels, and keeping nine devices alive for weeks. That is the part nobody documents.
Fleet requirements
Reduced to requirements, a fleet controller has to:
- Supervise one control agent per device and restart it on every failure mode: lock, memory pressure, USB brownout, xcodebuild timeout.
- Stream every screen to a browser over MJPEG and accept operator input when automation stalls.
- Persist "post this asset at 09:00 Tuesday" so it survives a reboot, a crash, and a redeploy.
- Synthesize input against a UI that never reaches XCTest quiescence.
- Resolve every tap target per device, per screen geometry, across TikTok releases.
- Guarantee at-most-once delivery for anything that publishes.
Each has a specific answer below.
Fleet output, 21 days
The test run behind the numbers at the top: nine devices, one post per device per day for three weeks, one scroll session per device most days, no manual intervention after registration.
| Metric | Value |
|---|---|
| Devices | 9 iPhone 8 class, one Mac mini, one powered hub |
| Window | 21 days continuous |
| Cadence | 1 post per device per day |
| Posts published | 189 |
| Mean views per post | 20,000 |
| Total views | 3.78M |
| Top post | 2.0M |
| Bottom post | 600 |
| Forged requests | 0 |
Distribution matters more than the mean: one post at 2.0M carries a large share of the total, which is the shape you expect from a feed that rewards outliers. The farm does the posting. Which asset goes out is decided upstream, in Handler.
Device layer
Pairing, DDI mount, and code signing through Xcode
A jailbreak would give us root on the phone. We did not want root. We wanted the input path Apple ships for its own UI tests: WebDriverAgent, an XCTest bundle that runs on the device, talks to the XCTest daemon over XCTestDaemonsProxy, and exposes a WebDriver-style HTTP server for synthesized pointer events, screenshots, and an MJPEG frame stream.
That decision means the farm never talks to the device at the USB level for control. It delegates the whole pair, trust, sign, and launch chain to Xcode, and inherits Xcode's rules:
- Pairing and trust are the OS's job. The phone must be plugged in, have accepted "Trust This Computer", and on iOS 16+ have Developer Mode enabled. The farm discovers a device over usbmuxd; it never initiates pairing.
- Signing reads the login keychain. WebDriverAgent is signed with your own Apple team using automatic signing. The keychain is only unlocked in a graphical session, which is why the build step must run from Terminal.app and fails over bare SSH. Every farm operator hits this once.
- A free Apple ID has a 100-UDID limit. Automatic signing embeds the connected UDIDs into the provisioning profile, so a new phone must be plugged in when the agent is built. At scale you count slots.
- The Developer Disk Image must be mounted for the phone's exact iOS version before xcodebuild can launch anything. Pairing once with Xcode does it.
The registration wizard in the dashboard is a UI over exactly this chain. It probes each link (Xcode selected, device visible, signing identity present, disk image mounted, agent reachable, video streaming, touch working, TikTok installed) and shows the specific fix before it writes the device to disk. Most support questions we got before the wizard existed were one of those eight checks failing silently.
WebDriverAgent supervision and USB port forwarding
WebDriverAgent dies. The phone locks, iOS reclaims memory, the USB hub browns out, xcodebuild times out. A farm is defined by what happens next.
A dedicated process called wda-service owns every agent. It launches xcodebuild test-without-building per active device, forwards the agent's HTTP port and MJPEG port over USB to the host (8100, 8101, ... and 9100, 9101, ...), watches health, and relaunches on failure. It is controlled over a Unix socket and takes a directory lock so a second copy cannot start; the second one sees the first is healthy and exits.
Health is per device and has real states, not a boolean: ready, unlock-required, error, and so on. unlock-required matters because a locked phone is the most common reason a job stalls, and the fix is different from every other failure.
The dashboard's remote control goes straight to the agent, not through Appium. The device grid polls stills; the device page streams the MJPEG feed and aborts the upstream when your browser tab closes, so idle tabs do not saturate the hub.
Two source patches to WebDriverAgent
Stock WebDriverAgent could not do two things a farm needs. Both fixes are small source patches applied at build time and ship in the repo.
Import media into Photos. TikTok's composer picks from the camera roll, so a post job has to get a video onto the phone first. We added a /wda/import-media route that takes a file as base64 JSON, requests add-only Photos permission, writes the asset through PHPhotoLibrary, and returns the new asset count. That count is load-bearing later. The route caps files at 350 MB because base64 plus a JSON copy runs into Node's string limit before the phone runs out of room.
Press hardware buttons without a session. Unlocking a phone means pressing Home, typing a passcode on the keypad, and doing it before any Appium session exists. Stock WDA only exposes /wda/pressButton inside a session. We registered the same handler sessionless. The passcode keypad has its own coordinate layout so auto-unlock works on the lock screen, which has no accessibility tree worth querying.
Client instrumentation
XCTest quiescence and waitForIdleTimeout: 0
XCTest, and therefore Appium, wants an app to settle before it acts. It waits for the UI to become quiescent, then resolves an element, then taps it. TikTok's home feed is a video that never stops, with a live-updating overlay, and it is never quiescent. The first version of the scroll loop would issue a swipe and hang until the proxy timed out.
Two settings fix it and both are in the capabilities we send:
'appium:waitForIdleTimeout': 0 // do not wait for quiescence, ever coordinate taps and swipes only // bypass element lookup on the feed
Every gesture in the scroll loop is a raw pointer action at a coordinate: pointer down, pause 100 ms, move over 450 ms, pointer up. No accessibility query on the feed, ever. Element queries are used only on screens that are static, like the "Use this sound" button on a music page.
Point-space coordinate profiles and per-device overrides
Once you stop querying elements, you need to know where things are. A coordinate profile is the full set of tap targets for one screen geometry: the five tab-bar buttons, the composer, the media picker grid, the caption field, the keyboard dismiss button, Post and Drafts, the like and save icons, and the swipe vector. All in points, not pixels, because the agent reports a point-sized screen and screenshots come back at device-pixel resolution. Every pixel measurement is divided by the scale factor before it becomes a tap.
The shipped profile is iphone8, 375 by 667 points. It also fits the iPhone SE 2 and 3 and the iPhone 7, which is why a farm of used SE phones is the cheapest thing you can build. Any operator can re-point the fifteen single-tap targets per device from the dashboard by clicking on the live screen; the override is stored on that device and merged over the profile at runtime. The picker grid, the swipe vector, and the passcode keypad are not single points and stay profile-level.
Locating like and save by normalized cross-correlation
The like and save buttons are the two targets that move most often. They sit in a column on the right edge, and their vertical position shifts with caption length, with whether a sound row is present, and between TikTok releases. A fixed coordinate was right most of the time and silently wrong the rest.
So the scroll job locates them at runtime. It takes a screenshot, reduces it to a max-channel intensity map, renders a heart and a bookmark template from SVG paths at the device's scale factor, mean-centres both, and runs normalized cross-correlation over a search strip: the right 90% to 96% of the width, between 25% and 76% of the height, stepping every three pixels. It keeps the top thirty candidates for each shape.
Then it applies the one fact that makes the detector reliable: the two icons are a pair. Bookmark sits below heart, within 24 points horizontally, separated by 14% to 25% of the screen height. It picks the pair whose weaker score is highest. If that score is under 0.18 it gives up and falls back to the profile coordinates, and says so in the log. No model, no training data, about 150 lines.
Pixel sampling and Tesseract OCR for UI state
Two other places where the UI tree is useless and pixels are not.
Checkboxes. TikTok's media picker has "Select multiple" and "Use layout" toggles that persist between picker sessions. A blind tap assuming they start off will turn an already-on "Select multiple" off and silently drop you into single-select. Before deciding to tap, the job samples a 20-pixel radius around the checkbox and counts pixels that are TikTok red (R over 200, G under 90, B under 110). More than 50 red pixels means checked. It taps only if the state is wrong, re-checks, and gives up after three tries.
Which account is signed in. A phone can hold several TikTok accounts and a job can name one. Confirming the switch means reading the handle on the profile page, and the handle is text. We run Tesseract on the screenshot, parse the TSV output, keep only word-level rows with confidence over 40, and match the handle exactly first, then by substring with a length-ratio guard so a stray misread glyph does not match everything. Every fuzzy match is logged with what OCR actually saw.
The account switcher is where dynamic target location failed. A fresh TikTok launch pops a transient tooltip over the profile header, different text each time, and it swallows taps. We first located the switcher trigger dynamically by OCR. It was not reliable enough to keep. What held up under repeated real-device runs was a fixed, live-calibrated coordinate plus four retries with an OCR check for the word "Switch" after each. The error message when it fails includes every word OCR could see, which is what you need at 2am.
An idempotent post pipeline
The post job is the one where a retry can do damage. The flow is: unlock, import media to Photos, open the composer (or deep-link a sound and tap "Use this sound"), select media from the picker, Next, Next, caption, then Post or Drafts.
Two details in the picker. Photos "Recents" is newest-first, so files are imported in reverse order and the asset count from the import route is used to compute the exact cell of each one. And the composer-to-picker stretch is the fragile part (flaky toggles, tooltips, timing), so it is retried up to three times with a full app relaunch between attempts.
Everything after the caption screen is never retried. A second Post tap after a first one that actually succeeded is a duplicate post, and a duplicate is worse than one clean failure that a human reads in the log. After Post the job deliberately holds the session open for 60 seconds, because the upload continues in the background and tearing down early can interrupt it.
Behavioural model
Stochastic interaction profiles
A scroll session has a duration and a personality. The personality is a small table of probabilities; the numbers below are the ones shipped in the repo.
| Skimmer | Casual | Engaged | |
|---|---|---|---|
| Watch per video | 1.5 to 4 s | 4 to 9 s | 8 to 18 s |
| Like chance | 8% | 18% | 35% |
| Save chance | 2% | 6% | 15% |
| Linger chance | 5% | 10% | 20% |
| Linger extra | 4 to 8 s | 8 to 15 s | 15 to 30 s |
Each decision is an independent Bernoulli draw against the profile; watch and linger durations are uniform within their bounds and clamped to the session deadline. Likes are a double tap 130 ms apart, so UIKit's gesture recognizer classifies it as double-tap-to-like and not two taps. Every interaction has a 200 to 600 ms pause before it. None of this is a claim about what TikTok's ranking models do or do not detect. It is the smallest parameter set that made a session read as human to a person watching the live feed.
Cooperative cancellation over SIGTERM
Every wait in the loop races against a stop signal. Pressing Stop in the dashboard sends SIGTERM to the job; the current sleep is cut short, the loop exits, the Appium session is deleted, and the job reports reason=stopped with the counts it reached. Nothing waits out a 30-second linger to notice it was cancelled.
Durable scheduling
Versioned task envelopes on pg-boss
Everything durable is in PostgreSQL. Schedules, executions, attempts, logs, and uploaded assets are tables. The job queue is pg-boss, also in Postgres, with one queue per device so a stuck phone never blocks its neighbour.
Every schedule and every execution row carries a task envelope:
pluginId "com.git-agni.tiktok" taskType "doomscroll" | "post" taskVersion 1 payload validated, version-specific JSON
The version is stored, not implied. If you ship version 2 of the post task and uninstall version 1, every old schedule fails loudly instead of quietly running the new contract. This sounds like ceremony until the day a payload field changes meaning.
The worker materializes due schedules every five seconds and picks up newly registered phones every thirty. A run has a window, thirty minutes by default; past it, an execution is abandoned as "window expired" rather than firing a 9am post at 4pm because the Mac was asleep.
Operations
launchd, file descriptors, and powered USB
The production shape is one Mac mini, a powered USB hub, one iPhone per port, and four launchd agents: Appium, wda-service, the worker, and the web dashboard. Three settings on the Mac make it hold:
- Raise open-file limits. WebDriverAgent plus one MJPEG stream per phone is hungry.
- Disable sleep, display sleep, and disk sleep. A sleeping Mac drops USB and takes every agent with it.
- Use a hub with its own power. Phones charge from it; an unpowered hub browns out under load and the failure looks like random WDA crashes.
Loopback bind and the authentication gate
The dashboard binds to 127.0.0.1 by default. The server refuses any other bind address unless an authentication provider is configured, and it fails at startup rather than warning. A farm cannot end up on the open internet by accident.
Device passcodes live in a git-ignored devices.json with mode 0600, not in environment variables. Plugins are trusted code with the same access as the service account, so the docs say to pin them exactly and review them like server code.
The public/private boundary
The open-source repo is the whole application. The private repo that sits on top of it is small and is the only thing we keep: our auth provider, our production configuration, the deploy script, and the LaunchAgent install for our own machines. It pins the public repo by exact commit. Our real device list, accounts, and content never leave the Mac.
The alternative writeups in this space sell you the code. We are not selling code. The farm is the free half of Handler.
Reference
Source
git clone https://github.com/Git-Agni/prod-FARM-IOS-Core.git phone-farm
Apache-2.0. Node 22, PostgreSQL, Xcode, one iPhone on iOS 16 to 18, and a free Apple developer team. The exact seven steps, in order, are at gethandler.ai/ios-farm. Follow them verbatim on a fresh Mac and you end with a registered phone running automation.
Handler
The farm posts. Handler decides what to post. It watches TikTok in your app's niche, scores the outliers that are pulling far above their creator's baseline, explains why each one worked, and gives you a one-click version of it with your own product in it. The first six picks are free at gethandler.ai.
FAQ
Does it need a jailbreak? No. Stock iPhones, stock TikTok, signed with your own Apple team.
Is this against TikTok's terms? Automating the app is. So is every scheduler and every growth tool. Run accounts you own and behave like a person; the personalities above are the floor, not the ceiling.
Android? Not in this repo. The architecture is device-agnostic above the agent layer, and an Android plugin would replace WebDriverAgent with UIAutomator2 behind the same task envelope. We have not built it.
Other apps? Yes. The TikTok automation is a plugin against a stable interface. A plugin gets versioned tasks, the device, per-device data, uploaded assets, a workspace, cancellation, durable logs, and safe device primitives. The minimal example is under 100 lines.
How many phones per Mac? We run 9 on one Mac mini. Each phone gets its own agent and ports; the limits are USB power and the open-file ceiling, not the scheduler.
Where do I ask something? Open an issue on the repo, or find An on X at @consumerxai.
