Give your agent an Android sandbox
API + MCP first. Mobilebox runs Android 14 arm64 sandboxes that your agent controls through the screen. Start with Settings: no downloaded app, account login, or external service is needed.
Get a key
Sign in at /login using a configured GitHub/Google provider or an existing team-issued beta key, then create an agent key in /dashboard. Issued agent keys cannot sign into the browser or manage other keys. Your browser login is separate from the key sent to REST or MCP. Check the account dashboard for available access and balance before starting a phone.
export MSB=https://mobilebox.dev
# Load your real key through your shell or secret manager:
# export MOBILESANDBOX_API_KEY=YOUR_API_KEY
curl --fail-with-body "$MSB/v1/account" \
-H "Authorization: Bearer $MOBILESANDBOX_API_KEY"Existing environment-variable names stay MOBILESANDBOX_API_KEY and MOBILESANDBOX_URL for SDK/CLI compatibility. Mobilebox is the public product name. Never commit a key or paste one into a model prompt.
Run the first loop
Save this as smoke.mjs and run it with Node 24. It renames a disposable phone through English Android Settings, verifies the saved name, parks and resumes the same phone, then verifies persistence. It checks HTTP and action errors and deletes the phone even after failure. Use current screen labels if you choose another locale.
const base = process.env.MOBILESANDBOX_URL ?? 'https://mobilebox.dev'
const key = process.env.MOBILESANDBOX_API_KEY
if (!key) throw new Error('Set MOBILESANDBOX_API_KEY')
async function call(path, method = 'GET', body) {
const response = await fetch(base + path, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(65_000),
})
const text = await response.text()
const value = text ? JSON.parse(text) : null
if (!response.ok) throw new Error(JSON.stringify(value))
return value
}
let id, taskFailed = false
async function ready(phone) {
const deadline = Date.now() + 180_000
while (phone.status === 'creating' || phone.status === 'starting') {
if (Date.now() >= deadline) throw new Error('Boot deadline exceeded')
phone = await call(`/v1/phones/${id}?wait=ready&timeout=50`)
if (phone.status !== 'ready') await new Promise(r => setTimeout(r, 1000))
}
if (phone.status !== 'ready') throw new Error(JSON.stringify(phone))
return phone
}
async function act(actions) {
const result = await call(`/v1/phones/${id}/actions`, 'POST', { actions })
const failed = result.results.find(item => !item.ok)
if (failed) throw new Error(JSON.stringify(failed.error))
return result.screen
}
const about = [
{ type: 'open_app', package: 'com.android.settings' },
{ type: 'scroll_to', text: 'About phone' },
{ type: 'tap', target: { text: 'About phone' } },
]
const savedName = 'Mobilebox smoke test'
const verify = screen => {
if (screen.elements.find(item => item.text === 'Device name')?.summary !== savedName) {
throw new Error(`Saved name was not verified: ${screen.text}`)
}
}
try {
const phone = await call('/v1/phones?wait=ready&timeout=50', 'POST', {
name: 'settings-smoke', idle_timeout: 300, max_duration: 300,
})
id = phone.id // keep this id; do not create again while it boots
await ready(phone)
console.log((await call(`/v1/phones/${id}/observe`)).text)
verify(await act([
...about,
{ type: 'tap', target: { text: 'Device name' } },
{ type: 'type', text: savedName, clear: true, target: { id: 'android:id/edit' } },
{ type: 'tap', target: { text: 'OK' } },
{ type: 'wait_for', text: 'visible to apps' },
{ type: 'tap', target: { text: 'OK' } },
]))
const parked = await call(`/v1/phones/${id}/park`, 'POST')
if (parked.status !== 'parked') throw new Error(`Unexpected park status: ${parked.status}`)
await ready(await call(`/v1/phones/${id}/start?wait=ready&timeout=50`, 'POST'))
verify(await act(about))
console.log('Verified: saved Android setting survived park and resume.')
} catch (error) {
taskFailed = true
throw error
} finally {
if (id) {
try { await call(`/v1/phones/${id}`, 'DELETE') }
catch (cleanupError) {
console.error(`Cleanup failed for ${id}:`, cleanupError)
if (!taskFailed) throw cleanupError
}
}
}To make a create safe to retry, send an Idempotency-Key header with a fresh unique value (e.g. crypto.randomUUID()) and resend the same request with the same key if the response is lost: within 24 hours you get the same phone, not a second one. Without a key, list phones before retrying; a phone may already exist. Server idle and duration limits are a cleanup backstop. A failed DELETE can still leave a phone: inspect your account if cleanup reports an error.
Use curl for individual calls
# ID is the phone id returned by create; the phone must be ready.
curl --fail-with-body "$MSB/v1/phones/$ID/observe?format=text" \
-H "Authorization: Bearer $MOBILESANDBOX_API_KEY"
curl --fail-with-body "$MSB/v1/phones/$ID/screenshot" \
-H "Authorization: Bearer $MOBILESANDBOX_API_KEY" -o screen.png
curl --fail-with-body -X POST "$MSB/v1/phones/$ID/apps" \
-H "Authorization: Bearer $MOBILESANDBOX_API_KEY" \
-H 'Content-Type: application/octet-stream' --data-binary @app.apk
# Keep installed apps/data/logins; shut down the running phone.
curl --fail-with-body -X POST "$MSB/v1/phones/$ID/park" \
-H "Authorization: Bearer $MOBILESANDBOX_API_KEY"The SDK and CLI in the repository wrap the same API. They are source files in this private checkout, not a published npm package: import MobileSandbox from ./src/sdk.ts or run node src/cli.ts for CLI help. Existing class and environment names remain compatible.
Connect an MCP client
Use stateless Streamable HTTP at https://mobilebox.dev/mcp with Authorization: Bearer YOUR_API_KEY. Get your client’s current configuration syntax from its documentation; the transport URL and bearer header are the service contract.
{
"mcpServers": {
"mobilebox": {
"type": "http",
"url": "https://mobilebox.dev/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}This is an illustrative configuration shape. Configure the actual key through the client’s secret/env mechanism instead of putting it in a shared file. Ask the client to list tools, then give it the agent guide.
First task: “Create a Mobilebox phone, open Android Settings, read a visible label, then park the phone even if the task fails.” Tools: create_phone, act, observe, park_phone. The service does not supply a model or a hosted task runner.
Understand the screen
Each line looks like [5] TextField "Draft" [focused] (540,865): reference, type, text, state, center. Prefer text or resource-id targets. A ref is from the latest screen; stale_ref means observe again. type needs a focused field or an explicit target.
Action batches return per-action results and the new screen. HTTP 200 does not guarantee every action succeeded. Inspect results, verify the intended outcome, and continue from current state after a failure.
Stop deliberately
Park keeps apps/data/logins and stops the running phone. Apps restart after resume; unsaved process state is not retained. Delete removes everything. Defaults are five idle minutes and fifteen running minutes; pass idle_timeout and max_duration in seconds to bound your own task. See billing and lifecycle.
Source: QUICKSTART.md in the repo, rendered at build time.