Before you start
You’ll need a Mobilerun API key, Bun, and an available Android cloud phone. Keep your API key on the server.
Get an API keyStart with the example
The embedded task runner gives you a working starting point: a phone stream, a task form, results and cancellation. Run it locally, then bring the flow into your app.
git clone https://github.com/droidrun/mobilerun-examples.git
cd mobilerun-examples/examples/typescript/task-runner
bun install
cp .env.example .env
# Add your server-side MOBILERUN_CLOUD_API_KEY
bun run dev You’re ready when the example opens at localhost:3000 and you can select a phone.
The local example uses existing devices. Add your own authentication and customer mapping before making it available to users.
Integrate into your app
Use @mobilerun/sdk in your backend for devices, jobs
and lifecycle. Use @mobilerun/react in your frontend
for the interactive phone. Your existing app owns customer
authentication and the customer-to-phone mapping.
npm install @mobilerun/sdk@5.6.0 @mobilerun/react@0.1.0 react react-dom Configure the server SDK TypeScript
import Mobilerun from "@mobilerun/sdk";
import { createPhoneIntegration, type CustomerStore } from "./server";
export function configurePhones(store: CustomerStore) {
const apiKey = process.env.MOBILERUN_CLOUD_API_KEY;
if (!apiKey) throw new Error("MOBILERUN_CLOUD_API_KEY is not configured");
const client = new Mobilerun({ apiKey });
return createPhoneIntegration(client, store);
}
// Bind these functions to authenticated routes in your existing backend:
// POST /api/phone -> phones.provisionPhone(session)
// GET /api/phone/stream -> phones.streamResponse(session)
// POST /api/jobs -> phones.runJob(session, persistedRequestId)
// GET /api/jobs/:taskId -> phones.jobResult(session, taskId)
// POST /api/phone/otp -> phones.submitOtp(session, challengeId, code)
// POST /api/phone/stop -> phones.parkPhone(session)
// POST /api/phone/resume -> phones.resumePhone(session)
// session is resolved by your server auth middleware, never request JSON.
Bind the service functions to authenticated routes in your existing
backend. The CustomerStore interface specifies the
database, locking, job ownership and OTP checks your app implements.
Resolve session.customerId from server-side
authentication.
Return scoped stream credentials Backend endpoint
async function streamResponse(session: CustomerSession) {
const deviceId = await assignedPhone(session);
const device = await client.devices.retrieve(deviceId);
return Response.json(
{
streamUrl: device.streamUrl || null,
streamToken: device.streamToken || null,
state: device.state,
},
{ headers: { "Cache-Control": "no-store" } },
);
}
These are typed integration building blocks, with explicit hooks for
your own app. Keep the API key on the server. Stream responses use Cache-Control: no-store.
You’re ready when your authenticated backend returns credentials for the current customer’s assigned phone.
Give your customer a phone
Create a phone for the first session. Save its ID against the customer in your own database, so every later visit returns to the same device.
Provision or reuse the phone TypeScript SDK
async function provisionPhone(session: CustomerSession) {
return store.withPhoneLock(session.customerId, async () => {
let deviceId = await store.findPhone(session.customerId);
if (!deviceId) {
const phone = await client.devices.create(
{
deviceType: "android_cloud_phone",
billing: "minute",
name: `phone_${session.customerId}`,
},
{ maxRetries: 0 },
);
deviceId = phone.id;
await store.savePhone(session.customerId, deviceId);
} else {
const phone = await client.devices.retrieve(deviceId);
if (phone.state === "stopped") await client.devices.resume(deviceId);
}
await client.devices.waitReady(deviceId);
return { deviceId };
});
} billing=minute selects minute billing. Device
allocation starts runtime usage. The adapter saves the mapping
before waiting for readiness and locks the customer slot to avoid
duplicate phones. If a create response is lost, reconcile the
allocation before retrying.
You’re ready when the saved phone belongs to the correct customer and is ready, including after a stop and reconnect.
Connect once. Keep the session.
Install the app and let your customer sign in on their assigned phone. Closing your product tab does not erase the phone. On the next visit, reuse the saved device ID, resume it if parked, then fetch fresh stream credentials.
The phone keeps the app session.
Installed apps, local app data and saved login state stay with the same phone through supported stop and resume. Skip login while the app session remains valid. The app provider can still expire a session or require another OTP.
Add the React stream React component
"use client";
import { DeviceStream } from "@mobilerun/react";
import "@mobilerun/react/styles.css";
import { useCallback, useEffect, useRef, useState } from "react";
export interface StreamCredentials {
streamUrl: string | null;
streamToken: string | null;
state: string;
}
export async function fetchStream(
signal?: AbortSignal,
): Promise<StreamCredentials> {
const response = await fetch("/api/phone/stream", {
credentials: "same-origin",
cache: "no-store",
signal,
});
if (!response.ok) throw new Error(`Stream unavailable (${response.status})`);
const data: unknown = await response.json();
if (!data || typeof data !== "object")
throw new Error("Invalid stream response");
const { streamUrl, streamToken, state } = data as Partial<StreamCredentials>;
const ready =
typeof streamUrl === "string" &&
streamUrl.length > 0 &&
typeof streamToken === "string" &&
streamToken.length > 0;
const waiting = streamUrl === null && streamToken === null;
if (typeof state !== "string" || !state || (!ready && !waiting)) {
throw new Error("Invalid stream response");
}
return {
streamUrl: streamUrl ?? null,
streamToken: streamToken ?? null,
state,
};
}
export function PhonePanel() {
const [credentials, setCredentials] = useState<StreamCredentials>();
const [error, setError] = useState<string>();
const pending = useRef<AbortController | null>(null);
const reloadCredentials = useCallback(async () => {
pending.current?.abort();
const controller = new AbortController();
pending.current = controller;
try {
const next = await fetchStream(controller.signal);
if (!controller.signal.aborted) {
setCredentials(next);
setError(undefined);
}
} catch {
if (!controller.signal.aborted) {
setCredentials(undefined);
setError("Unable to load your phone. Try again.");
}
}
}, []);
useEffect(() => {
void reloadCredentials();
return () => pending.current?.abort();
}, [reloadCredentials]);
useEffect(() => {
if (!credentials || credentials.streamUrl || error) return;
const timer = window.setTimeout(() => void reloadCredentials(), 3000);
return () => window.clearTimeout(timer);
}, [credentials, error, reloadCredentials]);
if (error)
return (
<div role="alert">
{error} <button onClick={() => void reloadCredentials()}>Retry</button>
</div>
);
return (
<DeviceStream
streamUrl={credentials?.streamUrl ?? undefined}
streamToken={credentials?.streamToken ?? undefined}
placeholderLabel={credentials?.state ?? "loading"}
hasControl
muted
onStreamHealed={reloadCredentials}
/>
);
}
Attach a saved login to a task Credentials SDK
import Mobilerun from "@mobilerun/sdk";
// Customer-specific opaque references come from YOUR authorized backend mapping.
// Initialize the package once through credentials.packages.create({ packageName }).
// Collect secrets through your authenticated customer flow, never a task prompt.
// twoFactorSecret is the authenticator setup secret, not a temporary OTP code.
export async function saveLogin(
client: Mobilerun,
packageName: string,
credentialName: string,
email: string,
password: string,
twoFactorSecret?: string,
) {
await client.credentials.packages.credentials.create(packageName, {
credentialName,
fields: [
{ fieldType: "email", value: email },
{ fieldType: "password", value: password },
...(twoFactorSecret
? [{ fieldType: "two_factor_secret" as const, value: twoFactorSecret }]
: []),
],
});
return { packageName, credentialName };
}
export async function runWithSavedLogin(
client: Mobilerun,
deviceId: string,
packageName: string,
credentialName: string,
) {
return client.tasks.run({
deviceId,
task: "Sign in to the app, then read the latest orders. Do not edit or send anything.",
credentials: [{ packageName, credentialNames: [credentialName] }],
maxSteps: 30,
executionTimeout: 180,
});
}
Keep credential names unique per customer. Your backend must check customer ownership before saving or attaching a credential; the API key belongs on the server.
TOTP through the Credentials API
If the app supports authenticator-based 2FA, have the customer
register a dedicated authenticator for this workflow. Save its
setup secret as two_factor_secret in their app
credential through the Credentials API, then attach that
credential to tasks that need to sign in.
Store the setup secret, not a temporary six-digit code. Complete the app’s verification step to confirm enrollment. SMS and email codes still use the customer OTP flow below.
The saveLogin helper accepts an optional
twoFactorSecret for a new credential. For an existing
credential, add or update its two_factor_secret field
through the API. Keep setup secrets out of prompts, logs and
analytics.
Handle OTP in your product
Show “Verification code needed” with a code field in your product. Your backend binds a short-lived, single-use challenge to the authenticated customer and their phone, then types the code into the verified OTP field.
Pass the customer’s OTP to the phone TypeScript SDK
async function submitOtp(
session: CustomerSession,
challengeId: string,
code: string,
) {
// Example accepts numeric 4–10 digit codes; match your target app's format.
if (!/^\d{4,10}$/.test(code)) throw new Error("invalid_otp");
return store.withPhoneLock(session.customerId, async () => {
if (await store.agentIsRunning(session.customerId))
throw new Error("agent_still_running");
const deviceId = await assignedPhone(session);
const challenge = await store.claimOtpChallenge(
session.customerId,
challengeId,
Date.now(),
);
if (
!challenge ||
challenge.expiresAt <= Date.now() ||
challenge.deviceId !== deviceId
) {
throw new Error("otp_challenge_unavailable");
}
await store.assertOtpFieldFocused(deviceId, challenge);
await client.devices.keyboard.write(
deviceId,
{
text: code,
clear: true,
},
{ maxRetries: 0 },
);
// Accepted input is not proof of login. Verify the app's signed-in screen.
return { accepted: true };
});
} Stop any running agent and wait for terminal status before the customer takes over. Recheck the focused OTP field while holding the phone lock. Keep the code out of prompts, recordings, logs and analytics; expire the challenge and offer a fresh one if needed. After input, verify the app’s signed-in screen before starting the business job.
App session, product session and stream token are different. Persist the customer-to-phone mapping and app state. Use your normal product authentication, and refresh stream credentials when reconnecting.
You’re ready when closing and reopening your app returns to the same phone, and expired app logins can reconnect with a password or OTP.
Run a job. Show the result.
Send a task to the assigned phone from your backend. Set a step limit, a timeout and an output schema, then show the final result in your own interface.
Run a job and read its result TypeScript SDK
async function runJob(session: CustomerSession, requestId: string) {
return store.withPhoneLock(session.customerId, async () => {
if (await store.phoneIsBusy(session.customerId))
throw new Error("phone_busy");
const deviceId = await assignedPhone(session);
// Persist this key BEFORE dispatch; reuse it for retries of the same request.
const requestKey = await store.jobRequestKey(
session.customerId,
requestId,
);
const task = await client.tasks.run(
{
deviceId,
task: "Read the latest orders. Do not edit or send anything.",
maxSteps: 30,
executionTimeout: 180,
outputSchema: {
type: "object",
properties: { summary: { type: "string" } },
required: ["summary"],
additionalProperties: false,
},
},
{ headers: { "Idempotency-Key": requestKey } },
);
await store.saveJob(session.customerId, task.id);
return { taskId: task.id };
});
}
async function jobResult(session: CustomerSession, taskId: string) {
if (!(await store.ownsJob(session.customerId, taskId)))
throw new Error("job_not_found");
const result = await client.tasks.getStatus(taskId);
return {
status: result.status,
succeeded: result.succeeded ?? null,
output: result.output ?? null,
};
}
Poll task status to start; use signed webhooks when you need
background delivery. Store task IDs against your customer, check
terminal status and succeeded, and show failures or
cancellations clearly.
You’re ready when a finished job returns a result to the right customer, including after they close the page.
Park now. Pick up later.
When the job and interactive session finish, park supported phones. Apps and local data stay on the device. Resume the same phone when the customer returns.
Stop and resume the same device TypeScript SDK
async function parkPhone(session: CustomerSession) {
return store.withPhoneLock(session.customerId, async () => {
if (await store.phoneIsBusy(session.customerId))
throw new Error("phone_busy");
const deviceId = await assignedPhone(session);
const { capabilities } =
await client.devices.retrieveCapabilities(deviceId);
if (!capabilities.stop) throw new Error("parking_unsupported");
await client.devices.stop(deviceId);
return { deviceId };
});
}
async function resumePhone(session: CustomerSession) {
return store.withPhoneLock(session.customerId, async () => {
const deviceId = await assignedPhone(session);
const phone = await client.devices.retrieve(deviceId);
if (phone.state === "stopped") await client.devices.resume(deviceId);
await client.devices.waitReady(deviceId);
return { deviceId };
});
}
The adapter checks device capabilities for stop: true
and refuses to park a busy phone. Confirm jobs are terminal before
parking, and confirm the device’s resulting state. Parking does not
cancel your plan or add-ons.
You’re ready when the same customer can resume the same device with its local app data preserved.
Make it yours
Your layout, your controls, your customer experience. The stream component adds no Mobilerun logo or app chrome. Before you ship, complete the parts your product owns.
- Customer access
Enforce ownership on stream, job, result, cancel and lifecycle requests.
- Queues and limits
Set customer quotas, concurrency and a backend idle timeout.
- Usage and billing
Keep minutes, agent credits and recurring costs separate. Reconcile your ledger with authoritative usage before invoicing customers.
- Recovery
Test expired logins, failed jobs, capacity errors and duplicate events.
The complete end-customer metering export needs a confirmed API contract before automatic rebilling can be documented.
Already have your own agent?
Use the device-control API or MCP. Keep the same customer mapping, embedded stream and phone lifecycle; bring your own execution logic.