# Run it every day

Source: https://phonebox.dev/docs/guides/run-on-a-schedule

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



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](/docs/api-reference/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 [#make-the-phone-once]

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

```bash
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](/app/keys) for the job, rather than reusing your own, so you can revoke it on its own.

## Nightly APK smoke test in GitHub Actions [#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`.

```yaml title=".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 [#a-cron-job-with-the-sdk]

On a server or a machine that is always on, a short script with the [SDK](/docs/interfaces/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.

```bash
npm install https://phonebox.dev/downloads/phonebox-0.1.0.tgz
```

```js title="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();
}
```

```text title="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 [#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](/docs/interfaces/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](/docs/using-phones/live-view) 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:

   ```markdown title="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:

   ```json
   {
     "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:

   ```text title="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 [#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](/app/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](/docs/guides/keep-costs-down) has more.
