New mobilerun VA is now in beta, a hosted 24/7 agent for mobile automation

A phone in
your product.

From first login to first job. Give each customer a persistent cloud phone, inside an experience that feels like yours.

7 steps React + TypeScript Android cloud phones

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 key
01

Start 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.

Terminal
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.

02

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.

Install in your app
npm install @mobilerun/sdk@5.6.0 @mobilerun/react@0.1.0 react react-dom
Configure the server SDK TypeScript
sdk.ts
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
GET /api/phone/stream
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.

03

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
Server-side request
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.

04

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
PhonePanel.tsx
"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
credentials.ts
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
POST /api/phone/otp
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.

05

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
Server-side request
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.

06

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
Device lifecycle
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.

07

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.

Explore the MCP
You’ve got the building blocks.

Give your product
a mobile layer.