android build api

Compile & run APKs
in the cloud.

POST Kotlin and XML. Get back screenshots, logcat, and a live emulator WebSocket. No SDK. No API keys. No signup.

base https://clawtank.app

00 /Public vs private

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.

01 /Fire and forget (public)

Send source, get three screenshots back. Enough to prove the app compiled, launched, and rendered. Free — uses the shared emulator pool.

POST /v1/build

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.

POST https://clawtank.app/v1/build

Body (JSON)

files required
Object mapping filename → source string. Include at least MainActivity.kt. Optional: colors.xml, strings.xml.
run
Boolean. Default true. Install & launch on emulator. When false, compile only.
logcat_lines
Integer 1–1000. Default 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
Example response
json
{
  "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":       []
}

02 /Scripted interactions

Same endpoint. Add a script array of timed actions — tap, swipe, key, screenshot — relative to app launch.

POST /v1/build + script

Each step has an at time (seconds after launch) and an action. Supported: screenshot, tap, swipe, key, wait.

POST https://clawtank.app/v1/build

Extra body field

script
Array of { "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": "demo"
  },
  "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
Example response
json
{
  "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}
  ]
}

03 /Live session

Open a WebSocket. Receive H.264 frames of the running app. Send taps, swipes, keys, and text in real time.

WS /ws/stream

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.

WS wss://clawtank.app/ws/stream

Control messages (client → server)

tap
{"type":"tap","x":540,"y":1180} — device pixel coordinates
swipe
{"type":"swipe","x1":…,"y1":…,"x2":…,"y2":…}
key
{"type":"key","code":4} — 3=HOME, 4=BACK, 24/25=volume
text
{"type":"text","text":"hello"} — inject into focused field

Server → client (JSON text frames)

queued
{"type":"queued","position":N,"queue_size":N,"pool_size":N,"inuse":N}
assigned / building / ready
ready includes width, height, codec, duration
tick
{"type":"tick","remaining":N} — session countdown
loot_screenshot
{"type":"loot_screenshot","idx":0,"data":"<base64 png>"}
expired / build_failed / error
Session end or failure frames
binary frames
H.264 NAL units; first byte flags (bit0 keyframe, bit1 config)
#!/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.

04 /Scripts & default files

Manage source files the studio and POST /v1/build can use. Default files are seeded and cannot be deleted.

GET /api/default-files

Lists the hard-coded default scripts (shown under scripts/ · default in the studio). These cannot be deleted. More defaults may be added over time.

Example response
json
{
  "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}
  ]
}
GET /api/files

List all scripts under scripts/ (defaults + user files).

GET /api/files/{name}

Read one file. Returns {"name","content"}.

PUT /api/files/{name}

Create or overwrite a file. Body: {"content": "…"}. Use this to add new sources before a live stream session.

DELETE /api/files/{name}

Delete a user file. Default files always return 403.

05 /Errors

HTTP uses standard status codes. WebSocket errors are JSON control frames.

400
invalid path / missing files
503
No worker online, or no free emulator
ws: error
{"type":"error","message":"…"}
ws: build_failed
{"type":"build_failed","code":N} — Gradle non-zero
ws: expired
Session timer reached zero; device returned to pool