POST Kotlin and XML. Get back screenshots, logcat, and a live emulator WebSocket. No SDK. No API keys. No signup.
Public POST /v1/build — free shared pool, no auth.
Private POST /v1/private/build — requires
Authorization: Bearer ct_… from your account,
charges 5¢ upfront via account credits Authorization: Bearer ct_… or x402 USDC (PAYMENT-SIGNATURE after HTTP 402). Non-refundable.
Send source, get three screenshots back. Enough to prove the app compiled, launched, and rendered. Free — uses the shared emulator pool.
Compiles the APK on managed infrastructure, installs it on a pooled emulator, launches it, and returns three PNGs (t+2s, t+3s, t+4s) plus logcat.
MainActivity.kt. Optional: colors.xml, strings.xml.true. Install & launch on emulator. When false, compile only.200. Max app logcat lines returned in the response.#!/usr/bin/env python3 import json, urllib.request, base64 URL = "https://clawtank.app/v1/build" MAIN = """package com.clawtank.app import android.os.Bundle import android.view.Gravity import android.widget.FrameLayout import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { override fun onCreate(s: Bundle?) { super.onCreate(s) val root = FrameLayout(this) root.setBackgroundColor(0xFF000000.toInt()) val tv = TextView(this).apply { text = "wow"; textSize = 48f setTextColor(0xFFFFFFFF.toInt()); gravity = Gravity.CENTER } root.addView(tv, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER)) setContentView(root) } } """ payload = { "files": { "MainActivity.kt": MAIN, "strings.xml": '<resources><string name="app_name">wow</string></resources>', "colors.xml": '<resources><color name="black">#FF000000</color></resources>', }, "run": True, "logcat_lines": 200, } req = urllib.request.Request( URL, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=300) as r: body = json.loads(r.read()) print(body["status"], body.get("build_ms"), "ms") for i, b64 in enumerate(body.get("screenshots") or []): open(f"shot_{i+1}.png", "wb").write(base64.b64decode(b64)) print(f" wrote shot_{i+1}.png") logcat = body.get("logcat") or "" print("--- logcat ---") print(logcat if logcat else "(empty)") if body.get("build_log"): print("--- build_log (tail) ---") print("\n".join((body["build_log"] or "").splitlines()[-20:]))
#!/usr/bin/env bash # Fire-and-forget build — returns 3 screenshots + logcat URL="https://clawtank.app/v1/build" curl -sS -X POST "$URL" \ -H 'Content-Type: application/json' \ -d @- <# extract screenshots with jq (optional) # jq -r '.screenshots[0]' response.json | base64 -d > shot_1.png
{
"status": "ok",
"device": "emulator-5554",
"platform": "android-34",
"build_ms": 41203,
"screenshots": ["iVBORw0KGgo…", "…", "…"],
"logcat": "05-01 12:34:56.789 D/clawtank: Cats\n…",
"logcat_lines": 200,
"build_log": "Building debug APK…\n…",
"steps": []
}
Same endpoint. Add a script array of timed actions — tap,
swipe, key, screenshot — relative to app launch.
Each step has an at time (seconds after launch) and an
action. Supported: screenshot, tap,
swipe, key, wait.
{ "at": number, "action": string, … }. Coordinates are in device pixel space.#!/usr/bin/env python3 import json, urllib.request, base64 URL = "https://clawtank.app/v1/build" MAIN = """package com.clawtank.app import android.graphics.Color import android.os.Bundle import android.view.Gravity import android.widget.FrameLayout import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Root container val root = FrameLayout(this).apply { setBackgroundColor(Color.parseColor("#111111")) } // Initial "tap me" label (acts as our button) val tapMe = TextView(this).apply { text = "tap me" textSize = 36f setTextColor(Color.WHITE) gravity = Gravity.CENTER isClickable = true isFocusable = true } // Make it fill the screen so the whole area is tappable root.addView( tapMe, FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) ) // On tap: turn everything red and show "swag" tapMe.setOnClickListener { root.setBackgroundColor(Color.RED) tapMe.text = "TAPPED" tapMe.textSize = 72f } setContentView(root) } } """ payload = { "files": { "MainActivity.kt": MAIN, "strings.xml": '<resources><string name="app_name">demo</string></resources>', }, "run": True, "script": [ {"at": 2.0, "action": "screenshot"}, {"at": 3.0, "action": "tap", "x": 360, "y": 800}, {"at": 4.0, "action": "screenshot"}, {"at": 5.0, "action": "swipe", "x1": 360, "y1": 1200, "x2": 360, "y2": 400}, {"at": 6.5, "action": "screenshot"}, ], } req = urllib.request.Request( URL, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=300) as r: body = json.loads(r.read()) print(body["status"], len(body.get("screenshots") or []), "shots") for i, b64 in enumerate(body.get("screenshots") or []): open(f"shot_{i+1}.png", "wb").write(base64.b64decode(b64)) print(f" wrote shot_{i+1}.png") print("--- logcat ---") print(body.get("logcat") or "(empty)") print("--- steps ---") print(body.get("steps") or [])
#!/usr/bin/env bash URL="https://clawtank.app/v1/build" curl -sS -X POST "$URL" \ -H 'Content-Type: application/json' \ -d @- <<'EOF' { "files": { "MainActivity.kt": "package com.clawtank.app\nimport android.os.Bundle\nimport android.view.Gravity\nimport android.widget.FrameLayout\nimport android.widget.TextView\nimport androidx.appcompat.app.AppCompatActivity\n\nclass MainActivity : AppCompatActivity() {\n override fun onCreate(s: Bundle?) {\n super.onCreate(s)\n val root = FrameLayout(this)\n root.setBackgroundColor(0xFF111111.toInt())\n val tv = TextView(this).apply { text = \"tap me\"; textSize = 36f; setTextColor(0xFFFFFFFF.toInt()); gravity = Gravity.CENTER }\n root.addView(tv, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))\n setContentView(root)\n }\n}\n", "strings.xml": "" }, "run": true, "script": [ { "at": 2.0, "action": "screenshot" }, { "at": 3.0, "action": "tap", "x": 360, "y": 800 }, { "at": 4.0, "action": "screenshot" }, { "at": 5.0, "action": "swipe", "x1": 360, "y1": 1200, "x2": 360, "y2": 400 }, { "at": 6.5, "action": "screenshot" } ] } EOF demo
{
"status": "ok",
"device": "emulator-5554",
"platform": "android-34",
"build_ms": 43890,
"screenshots": ["iVBORw0KGgo…", "…", "…"],
"logcat": "05-01 12:34:56.789 D/clawtank: …\n…",
"logcat_lines": 200,
"build_log": "Building debug APK…\n…",
"steps": [
{"at": 2.0, "action": "screenshot", "idx": 0},
{"at": 3.0, "action": "tap"},
{"at": 4.0, "action": "screenshot", "idx": 1}
]
}
Open a WebSocket. Receive H.264 frames of the running app. Send taps, swipes, keys, and text in real time.
Binary frames are raw H.264 (first byte = flags: bit0 keyframe,
bit1 config). Control messages are JSON text frames.
Upload sources first via PUT /api/files/… or use the
studio UI.
{"type":"tap","x":540,"y":1180} — device pixel coordinates{"type":"swipe","x1":…,"y1":…,"x2":…,"y2":…}{"type":"key","code":4} — 3=HOME, 4=BACK, 24/25=volume{"type":"text","text":"hello"} — inject into focused field{"type":"queued","position":N,"queue_size":N,"pool_size":N,"inuse":N}ready includes width, height, codec, duration{"type":"tick","remaining":N} — session countdown{"type":"loot_screenshot","idx":0,"data":"<base64 png>"}#!/usr/bin/env python3 # pip install websockets import asyncio, json, websockets URL = "wss://clawtank.app/ws/stream" async def main(): async with websockets.connect(URL, max_size=None) as ws: # wait for assigned / building / ready while True: msg = await ws.recv() if isinstance(msg, bytes): # H.264 frame — first byte is flags flags, payload = msg[0], msg[1:] print(f"frame flags={flags:#x} size={len(payload)}") continue data = json.loads(msg) print("<", data.get("type"), data) if data.get("type") == "ready": await ws.send(json.dumps({"type": "ready"})) # tap center of screen after a moment await asyncio.sleep(2) await ws.send(json.dumps({ "type": "tap", "x": data.get("width", 720) // 2, "y": data.get("height", 1280) // 2, })) if data.get("type") in ("expired", "emulator_offline", "error"): break asyncio.run(main())
# WebSocket streaming is best done from Python / Node / a browser. # Quick test with websocat (https://github.com/vi/websocat): websocat -b wss://clawtank.app/ws/stream # After the "ready" JSON frame arrives, send: # {"type":"ready"} # then control messages, e.g.: # {"type":"tap","x":360,"y":800} # {"type":"key","code":4} # Binary H.264 frames will print as raw bytes. # Prefer the Python sample above for real use.
Manage source files the studio and POST /v1/build can use.
Default files are seeded and cannot be deleted.
Lists the hard-coded default scripts (shown under
scripts/ · default in the studio). These cannot be
deleted. More defaults may be added over time.
{
"names": ["MainActivity.kt", "colors.xml", "strings.xml"],
"default_files": [
{"name": "MainActivity.kt", "deletable": false, "exists": true, "size": 1234},
{"name": "colors.xml", "deletable": false, "exists": true, "size": 400},
{"name": "strings.xml", "deletable": false, "exists": true, "size": 180}
]
}
List all scripts under scripts/ (defaults + user files).
Read one file. Returns {"name","content"}.
Create or overwrite a file. Body: {"content": "…"}.
Use this to add new sources before a live stream session.
Delete a user file. Default files always return 403.
HTTP uses standard status codes. WebSocket errors are JSON control frames.
invalid path / missing files{"type":"error","message":"…"}{"type":"build_failed","code":N} — Gradle non-zero