Custom Scripts
The built-in scripts cover the common flows. When you need something they do not — a step in a different order, a screen they never touch, or an app that is not TikTok or Instagram — you can write it yourself in any language and let TikMatrix hand you the phone.
Requirements
Custom scripts require a Pro, Team, or Business plan. Starter plan does not have access.
Your plan's device count is also the concurrency limit: a Pro plan (20 devices) can drive 20 phones at once, whether through built-in tasks, custom scripts, or a mix of both.
Two ways to run a script
Standalone
You run your program yourself. TikMatrix just lends you devices.
from tikmatrix import TikMatrix
client = TikMatrix()
for device in client.devices():
if device["busy"]:
continue
with client.device(device["serial"], label="my crawler") as d:
d.press("home")
print(d.info())
Good for one-off jobs, data collection, and anything you want to run from your own scheduler.
Managed
You register the program in TikMatrix and it becomes a task like any other. It gets the task queue, per-plan concurrency, automatic retries, the task log, and schedule templates. TikMatrix leases the device before starting your program and passes the lease id in its environment.
from tikmatrix import TikMatrix
with TikMatrix.from_env() as d: # device already leased
d.click(text="Log in")
print("done") # this line lands in the task log
Good for anything you want to run repeatedly, on a schedule, or across many devices.
Which one to pick
| Standalone | Managed | |
|---|---|---|
| Who starts it | You | The TikMatrix task queue |
| Device lease | You acquire it | Already held when your program starts |
| Retries, schedules, task log | You build them | Included |
| Runs across many devices | You loop yourself | One task per device, dispatched in parallel |
| Best for | Exploration, crawlers, one-off jobs | Anything you want to repeat |
You can start standalone while you get the flow working, then register the same file as a managed script — TikMatrix.from_env() is the only line that changes.
Getting started
1. Install the client library
pip install requests
Then copy tikmatrix.py from the SDK directory next to your script. The library is a single file with no other dependencies.
You do not have to use it — the API is plain JSON over HTTP, and the raw endpoints are documented below.
2. Write your script
from tikmatrix import TikMatrix
client = TikMatrix()
with client.device("192.168.1.5:5555") as d:
d.press("home")
d.adb("shell", "am", "start", "-a", "android.settings.SETTINGS")
d.wait_for(text="Settings", timeout=15)
d.screenshot("settings.png")
Run it with TikMatrix open and the phone connected. If it prints a device info dictionary, everything is wired up.
3. Register it (managed mode only)
Go to Devices → Custom Scripts → Add Script:
| Field | Meaning |
|---|---|
| Name | Shown in the script list and the task log |
| Command | The program line to run, e.g. python C:/scripts/my_flow.py |
| Working directory | Optional. Where the program starts |
| Platform | See platform modes below |
| Timeout | Seconds before the script is killed and the task marked failed. Default 1800 |
| Extra environment variables | Optional JSON object merged into the program's environment |
| Enabled | Turn a script off without deleting it. A disabled script cannot be dispatched |
Then press ▶ on the script row and pick your devices, exactly like a built-in script.
The AI Assistant can draft a custom script from a plain-language description and register it in one step. It shows you the whole file before anything is written to disk.
Device leases
A phone can only be driven by one thing at a time. Leasing it tells TikMatrix the device is busy, so:
- the task queue will not dispatch a task onto the same screen, and
- your JSON-RPC calls report agent health exactly as a built-in script does, so the watchdog sees a busy agent rather than a silent one.
A lease also consumes one device slot from your plan.
Leases expire — 120 seconds by default, 600 maximum. The Python library renews yours on a background thread and releases it when the with block ends, so a crashed script frees its device within seconds instead of holding it until you restart the app. If you are calling the API directly, you must send heartbeats yourself.
You can see every live lease, and force-release one, under Settings → Developer API → Active device sessions.
Platform modes
A registered script declares what it targets:
Generic — the device is handed over untouched. No app is started, no account switching, no input-method check, and nothing is closed afterwards. Use this to automate anything that is not TikTok or Instagram.
TikTok / Instagram — the app is opened and the account switched before your program starts, and the app is closed when it finishes, exactly as for a built-in script. TIKMATRIX_PACKAGE tells you which package was resolved. Use this to add a step the built-in scripts do not cover.
Environment variables
A managed script receives:
| Variable | Meaning |
|---|---|
TIKMATRIX_API_BASE | Server URL, e.g. http://127.0.0.1:50809 |
TIKMATRIX_SESSION_ID | The lease already held on your behalf |
TIKMATRIX_SERIAL | The device this task was dispatched to |
TIKMATRIX_PACKAGE | Resolved app package |
TIKMATRIX_PLATFORM | tiktok, instagram, or generic |
TikMatrix.from_env() reads all of these for you.
Standalone scripts get none of them — lease a device explicitly instead.
Anything you put in Extra environment variables is merged on top, which is the usual way to give one registered script per-run settings without editing the file.
Python library reference
TikMatrix — the connection
| Call | What it does |
|---|---|
TikMatrix(base_url=None, timeout=30.0) | Connect. Falls back to TIKMATRIX_API_BASE, then http://127.0.0.1:50809 |
client.devices() | Online devices, each with serial, real_serial and busy |
client.sessions() | Every live lease, including ones this process does not own |
client.device(serial, label=..., ttl_secs=120) | Lease a device and return a Device |
TikMatrix.from_env() | Adopt the device a managed script was started with |
Device — the phone
| Call | What it does |
|---|---|
d.info() | UIAutomator2 device info |
d.window_size() | (width, height) |
d.screenshot(path=None) | PNG bytes, optionally written to path |
d.hierarchy() | The current UI tree as XML |
d.find(text=, resource_id=, description=, class_name=) | Matching nodes, each with bounds and center |
d.exists(**criteria) | Whether anything matches |
d.wait_for(timeout=10.0, interval=1.0, **criteria) | Block until it appears, then return it |
d.click(timeout=10.0, **criteria) | Wait for an element, then tap its centre |
d.click_xy(x, y) | Tap a coordinate |
d.swipe(sx, sy, ex, ey, steps=20) | Swipe |
d.press(key) | back, home, recent, enter, … |
d.input_text(text) | Type into the focused field via the bundled fast-input IME |
d.jsonrpc(method, params=None, timeout=10) | Any UIAutomator2 method |
d.adb(*args, timeout_ms=None) | Run an ADB command |
d.release() | Release the lease. with does this for you |
find matches against the dumped UI tree, so when a selector misses you can print(d.hierarchy()) and look at exactly what it searched. The Element Inspector in the device view shows the same tree visually, which is usually the fastest way to find a resource-id.
input_text needs ADBIt sends a broadcast to the bundled input method, which goes through adb shell. Enable ADB access before using it, or it fails with 403.
Errors
The library raises two exceptions, both subclasses of RuntimeError:
| Exception | When |
|---|---|
DeviceBusyError | HTTP 409 — the device is already leased, or your plan has no free device slot |
TikMatrixError | Everything else: plan too low, lease expired, ADB disabled, selector never matched |
from tikmatrix import TikMatrix, TikMatrixError, DeviceBusyError
client = TikMatrix()
try:
with client.device("192.168.1.5:5555") as d:
d.click(text="Log in", timeout=20)
except DeviceBusyError:
print("someone else has that phone — try another one")
except TikMatrixError as exc:
print("failed:", exc)
In a managed script, letting the exception escape is usually the right thing to do: the non-zero exit marks the task failed and the traceback lands in the task log.
HTTP endpoints
Device operations need an x-session-id header naming a live lease. There is no API key: like the rest of the local API, these endpoints are unauthenticated — reaching the machine on the network is the access control. They send no CORS headers, so call them from a program (curl, Python, anything server-side) rather than from a page in a browser.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/rpc/devices | List online devices and whether each is busy |
POST | /api/v1/rpc/session | Lease a device → session_id |
POST | /api/v1/rpc/session/{id}/heartbeat | Extend the lease |
DELETE | /api/v1/rpc/session/{id} | Release the lease |
GET | /api/v1/rpc/session | List live leases |
POST | /api/v1/rpc/jsonrpc | Call a UIAutomator2 method |
POST | /api/v1/rpc/adb | Run an ADB command |
GET | /api/v1/rpc/hierarchy?serial= | Current UI tree as XML |
GET | /api/v1/rpc/screenshot?serial= | Current screen as PNG |
JSON replies use the same envelope as the rest of the local API — {"code": 0, "message": "success", "data": ...}, with a non-zero code on failure. hierarchy and screenshot return the raw body instead.
Example
# Lease a device
curl -X POST http://127.0.0.1:50809/api/v1/rpc/session \
-H "Content-Type: application/json" \
-d '{"serial":"192.168.1.5:5555","label":"curl test","ttl_secs":120}'
# {"code":0,"message":"success","data":{"session_id":"ff3ae079-...","serial":"192.168.1.5:5555", ...}}
# Drive it
curl -X POST http://127.0.0.1:50809/api/v1/rpc/jsonrpc \
-H "x-session-id: ff3ae079-..." \
-H "Content-Type: application/json" \
-d '{"serial":"192.168.1.5:5555","method":"deviceInfo","params":[]}'
# Keep it alive while you work
curl -X POST http://127.0.0.1:50809/api/v1/rpc/session/ff3ae079-.../heartbeat \
-H "Content-Type: application/json" \
-d '{"ttl_secs":120}'
# Give it back
curl -X DELETE http://127.0.0.1:50809/api/v1/rpc/session/ff3ae079-...
Errors
| Status | Meaning |
|---|---|
| 403 | Plan below Pro, no lease, lease expired, or ADB access disabled |
| 409 | Device already leased, or your plan has no free device slot |
Writing in another language
Nothing here is Python-specific. Any runtime that can make an HTTP request works — the managed-mode contract is only "read three environment variables, exit 0 on success".
// my_flow.js — register with: node C:/scripts/my_flow.js
const base = process.env.TIKMATRIX_API_BASE || "http://127.0.0.1:50809";
const serial = process.env.TIKMATRIX_SERIAL;
const session = process.env.TIKMATRIX_SESSION_ID;
async function jsonrpc(method, params = []) {
const res = await fetch(`${base}/api/v1/rpc/jsonrpc`, {
method: "POST",
headers: { "content-type": "application/json", "x-session-id": session },
body: JSON.stringify({ serial, method, params }),
});
const body = await res.json();
if (!res.ok || body.code !== 0) throw new Error(body.message || res.statusText);
return body.data;
}
console.log(await jsonrpc("deviceInfo"));
If the interpreter is not on PATH, give its full path in Command, e.g. C:/Program Files/nodejs/node.exe C:/scripts/my_flow.js.
Triggering a custom script from the API
Registered scripts can also be started through the Task Management API, so one script can queue follow-up work:
curl -X POST http://127.0.0.1:50809/api/v1/task \
-H "Content-Type: application/json" \
-d '{
"serials": ["192.168.1.5:5555"],
"script_name": "custom_script",
"script_config": {
"custom_script_id": 1,
"custom_script_platform": "generic"
}
}'
custom_script_id is the id of the script you registered.
ADB access
/api/v1/rpc/adb gives your scripts a device shell — you need it for pushing media, installing APKs, and changing system settings. Because it is a full shell on an endpoint that has no API key, it ships turned off. Enable it under Settings → Developer API → Allow ADB commands when you have a script that needs it; UI automation over /rpc/jsonrpc works without it.
While it is off, /api/v1/rpc/adb answers 403 and the rest of the API keeps working. Every ADB command a script runs is written to your log file.
Writing scripts that keep working
- Wait for the screen, do not sleep for it.
d.wait_for(...)returns as soon as the element is there; a fixed sleep is either slower than it needs to be, or too short on a bad day. - Check before you tap.
d.exists(...)on a consent dialog or a "not now" prompt costs one hierarchy dump and saves a run that would otherwise tap into nothing. - Print what you did. In managed mode stdout is the task log, and it is the only record of a run nobody was watching.
- Make a rerun safe. A retry re-runs the whole program, so a script that posts should check whether it already posted rather than assuming it starts from scratch.
- Keep one script to one job. Concurrency is per device, so ten small tasks across ten phones finish far sooner than one script looping over ten phones.
Notes and limits
- The command is executed directly, not through a shell, so
&&and|are treated as arguments rather than operators. Registercmd /c "..."(Windows) orsh -c "..."(macOS) if you want shell behaviour. - Quote paths that contain spaces:
"C:/Program Files/Python/python.exe" my_script.py. - A script that exceeds its timeout is terminated and the task is marked failed.
- A non-zero exit code marks the task failed; everything the script writes to stdout and stderr lands in the task log.
- Scripts run with the same permissions as TikMatrix itself. Only register programs you wrote or trust.
Troubleshooting
API access requires Pro or higher plan (403)
The licence on this machine is Starter or inactive. Check Settings → License.
Connection refused on 127.0.0.1:50809
TikMatrix is not running, or it is running as a different user. The server exists only while the app is open.
409 on every lease attempt Either the phone is genuinely busy — check Settings → Developer API → Active device sessions — or every device slot in your plan is already taken by running tasks.
The lease expires in the middle of a long step
The default TTL is 120 s and the library renews it in the background, so this usually means the script blocked its main thread for longer than the TTL. Raise ttl_secs (up to 600), or move the long work off that thread.
d.adb(...) fails with 403
ADB access is off. Turn it on under Settings → Developer API → Allow ADB commands.
A selector never matches
print(d.hierarchy()) shows the exact tree find searched. Text is matched exactly, so a trailing space or a localised label is the usual cause; matching on resource_id is more stable than on text.
The task is marked failed but the phone looks fine Read the task log. A non-zero exit — including an uncaught exception at the end of a successful run — fails the task even when the automation itself worked.
Next steps
- Local API Overview — authentication and response format
- Task Management API — create, query, retry and stop tasks
- AI Assistant — have a model draft and register a script for you
- SDK and examples on GitHub