Phonebox / Docs
Guides

Run it every day

Three recipes for work that repeats, such as a nightly APK smoke test in GitHub Actions, a cron job with the SDK, and a scheduled agent on a signed-in phone. Each checks the credit first and always parks the phone.

View as Markdown

Some phone work is worth doing once. Some repeats by nature: testing every build, a daily check of a mobile flow, a post or a report that goes out every morning. For that kind of work, keep one phone for the job, park it between runs, and start it on a schedule. A parked phone keeps its apps, files and signed-in accounts, and costs nothing while it waits.

Each recipe below does the same three things around the work itself:

  1. Checks the credit first. It reads the account, and when there isn't enough for the run it stops with a message that has top_up_url, a page where a person signs in and pays for credit, instead of failing quietly. An insufficient_credits error carries the same link in details.top_up_url.
  2. Starts a parked phone with a short max_duration, so a run that hangs can't keep billing.
  3. Always parks the phone, even when the run failed.

A run of up to 10 minutes reserves $0.60 when it starts, and you pay only for the seconds it runs. $20 of credit is about 5½ phone-hours, or about 33 ten-minute runs.

Make the phone once

Create the phone by hand, note its ID, and park it. Every run then starts this same phone.

phonebox create --name nightly-smoke --idempotency-key nightly-smoke-1 --no-wait
phonebox status ph_7kx2m6q4v3ta --wait
phonebox park ph_7kx2m6q4v3ta

The scheduled job needs the phone's ID and an API key. Create an Agent key under API keys for the job, rather than reusing your own, so you can revoke it on its own.

Nightly APK smoke test in GitHub Actions

This workflow builds your debug APK every night, installs it on the parked phone, opens it, waits for a screen you expect, saves a screenshot, and parks the phone. Store the key as the repository secret PHONEBOX_API_KEY and the phone's ID as the repository variable PHONEBOX_PHONE.

.github/workflows/nightly-phone.yml
name: Nightly phone smoke test
on:
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch:
jobs:
  smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      PHONEBOX_API_KEY: ${{ secrets.PHONEBOX_API_KEY }}
      PHONEBOX_PHONE: ${{ vars.PHONEBOX_PHONE }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: "17" }
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - run: npm install -g https://phonebox.dev/downloads/phonebox-0.1.0.tgz
      - name: Check Phonebox credit
        run: |
          phonebox account > account.json
          available=$(jq -r .available_usd account.json)
          if awk -v a="$available" 'BEGIN { exit !(a < 0.60) }'; then
            echo "::error::Phonebox credit is low (\$$available available). Add credit: $(jq -r .top_up_url account.json)"
            exit 1
          fi
      - run: ./gradlew assembleDebug
      - name: Smoke test on the phone
        run: |
          phonebox start "$PHONEBOX_PHONE" --max 10m
          phonebox apps "$PHONEBOX_PHONE" install app/build/outputs/apk/debug/app-debug.apk
          phonebox open "$PHONEBOX_PHONE" com.example.app
          phonebox wait "$PHONEBOX_PHONE" --text "Welcome" --timeout 30s
          phonebox screenshot "$PHONEBOX_PHONE" smoke.jpg
      - name: Park the phone
        if: always()
        run: phonebox park "$PHONEBOX_PHONE"
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: smoke-screenshot, path: smoke.jpg, if-no-files-found: ignore }
  • Replace com.example.app and "Welcome" with your app's package and a text its first screen shows.
  • A failed check fails the job, and GitHub emails you about a failed scheduled run. When credit is short, the error has the top-up link, and a refused phonebox start prints insufficient_credits with the same link in details.top_up_url.
  • The top-up link lets whoever has it pay for this project's credit, and shows the project's name. In a public repository, print https://phonebox.dev/app/billing instead, so the link stays out of public logs.
  • To test on every push as well, add push under on. Two runs at once would share the phone, so add a concurrency group to the job.

A cron job with the SDK

On a server or a machine that is always on, a short script with the SDK can run a daily check. This one opens a page in the phone's browser, waits for the text it should show and saves a screenshot. It prints nothing when all is well, so cron only emails you when something needs you.

npm install https://phonebox.dev/downloads/phonebox-0.1.0.tgz
daily-check.mjs
import { writeFile } from "node:fs/promises";
import { Phonebox, PhoneboxError } from "phonebox";

const pb = new Phonebox(); // reads PHONEBOX_API_KEY
const account = await pb.account();
if (Number(account.available_usd) < 0.6) {
  console.error(`Phonebox credit is low ($${account.available_usd} available). Add credit: ${account.top_up_url}`);
  process.exit(1);
}
const phone = await pb.phones.get(process.env.PHONEBOX_PHONE);
try {
  await phone.start({ maxDuration: 600 }); // waits until it is ready, with its apps and sign-ins kept
  await phone.open("https://example.com/pricing");
  await phone.waitFor({ text: "Pricing" }, { timeoutMs: 20_000 });
  await writeFile(`check-${new Date().toISOString().slice(0, 10)}.jpg`, await phone.screenshot({ format: "jpeg" }));
} catch (error) {
  if (error instanceof PhoneboxError && error.code === "insufficient_credits") console.error(`Phonebox credit ran out. Add credit: ${error.details.top_up_url}`);
  throw error;
} finally {
  await phone.park();
}
crontab -e
MAILTO=you@example.com
PHONEBOX_API_KEY=pbx_…
PHONEBOX_PHONE=ph_7kx2m6q4v3ta
0 7 * * * cd /opt/phone-checks && node daily-check.mjs

Keep the crontab readable only by you, since it holds the key. A failed waitFor throws, so the run ends with an error that cron emails, and the phone is parked either way.

A scheduled agent on a signed-in phone

Some daily work needs judgment rather than a fixed script, such as posting today's update in an app that has no API, or checking an inbox only the mobile app can reach. A coding agent you can run without a person, such as Claude Code with claude -p, can do it over MCP on a phone that is already signed in.

  1. Sign the phone in once. Make the phone as above, start it, and create a live view link with phonebox live ph_7kx2m6q4v3ta --expires 30m. Open it, sign in to the app yourself, then phonebox park ph_7kx2m6q4v3ta. The phone stays signed in while it is parked.

  2. Write the task down, with the credit check and the park in it:

    daily-task.md
    Use the Phonebox MCP tools.
    1. Call get_account. If available_usd is below 0.60, do nothing else and reply:
       "Phonebox credit is low. Add credit: <top_up_url>". Then stop.
    2. Call start_phone with phone_id ph_7kx2m6q4v3ta and max_duration 900.
    3. Open the app and post today's update from updates/today.md. Observe after every action.
    4. Call park_phone with phone_id ph_7kx2m6q4v3ta, even if a step failed.
    5. Reply with one line: what you posted, or what stopped you.
  3. Connect the agent to Phonebox with a config file, phonebox.mcp.json, that reads the key from the environment:

    {
      "mcpServers": {
        "phonebox": {
          "type": "http",
          "url": "https://phonebox.dev/mcp",
          "headers": { "Authorization": "Bearer ${PHONEBOX_API_KEY}" }
        }
      }
    }
  4. Schedule it. cron emails you the agent's one-line reply every morning, the top-up link included when credit is low:

    crontab -e
    MAILTO=you@example.com
    PHONEBOX_API_KEY=pbx_…
    0 8 * * * cd /opt/daily-agent && claude -p "$(cat daily-task.md)" --mcp-config phonebox.mcp.json --allowedTools "mcp__phonebox"

--allowedTools "mcp__phonebox" lets the agent use Phonebox's tools without asking, and nothing else. An idle phone parks itself 5 minutes after its last call, so a run the agent abandons stops billing on its own; max_duration caps the rest.

Keep runs cheap and safe

  • Give every run a short max_duration. A phone also parks itself when it has been idle for its idle_timeout, 5 minutes by default.
  • Run one job per phone. Two jobs on one phone act on screens they didn't read.
  • Set a monthly spending limit in Settings as a ceiling. A run past it is refused with spend_limit_reached, whose details.settings_url is where you raise it.
  • Keep costs down has more.

On this page