# tapflow — full documentation
> Every English documentation page of tapflow, concatenated. The index with one-line
> descriptions is at https://www.tapflow.dev/llms.txt, and each page below is also served on its own at
> the path under its `#` heading with a `.md` suffix — an index page at `
/index.md`.
> Korean translations live under `/ko/` and carry the same content.
>
> This file is about 332,000 characters long and the reference pages are at the end, so a tool that
> truncates or summarises a large fetch will not see them. Prefer the single page you need.
---
# https://www.tapflow.dev/automation/flows
# Flow Reference
::: warning Experimental
tapflow's **AI Automation axis** — the MCP server and the flow runner — is experimental. The manual QA dashboard is the mature, production path; this axis is additive and still maturing. Expect rough edges, especially in selector matching and in timing right after an app launches.
:::
A flow is a UI test scenario written in YAML, replayed deterministically by `tapflow flow run` with no LLM calls. The same flow always runs in the same order from the same input, so it is idempotent — and it costs nothing in API calls in CI.
::: info Two QA axes
tapflow has two QA axes. The browser dashboard is the **manual QA axis**, where your team tests by hand; the flow runner covered here is the **automated QA axis**, whose main stage is CI/CD.
Flows are not a language you hand-write from scratch — they are an artifact a coding agent generates or a recording produces. To author them with an agent, see [MCP in CI/CD](/automation/mcp-ci).
:::
## Flow file structure
A flow has three top-level keys.
```yaml
name: login-smoke
appId: com.example.app
steps:
- clearState
- launchApp
- assertVisible: "Sign in"
- tapOn: { id: "com.example.app:id/email" }
- inputText: "user@example.com"
- pressKey: Enter
- tapOn: "Sign in"
- assertVisible: { label: "Orders", timeout: 15 }
```
| Key | Required | Description |
|-----|----------|-------------|
| `name` | No | Name shown in reports. Defaults to the file name. |
| `appId` | Conditional | Bundle id of the app under test. Required when `clearState` is used without an argument. |
| `steps` | Yes | The list of steps, run top to bottom. |
Coordinates are normalized 0–1 everywhere. Selectors point at screen elements directly, so you will rarely write pixel coordinates into a flow.
## Steps
The vocabulary is deliberately small. These ten cover most scenarios.
| Step | Form | Behavior |
|------|------|----------|
| `clearState` | keyword or `clearState: ` | Resets the app data. The keyword form uses the top-level `appId`. |
| `launchApp` | keyword | Launches the build under test and waits for its screen to settle: the role, label and id of every UI tree element unchanged for a second, so a splash screen is not taken for the first screen. A screen with no UI tree elements, such as a game, moves on after that second too. A screen whose labels keep changing moves on 3 seconds after it was first read, and one that cannot be read at all after the default per-selector wait. The wait never fails the step. A splash that stays unchanged for longer than a second can still be taken for the first screen. |
| `tapOn` | selector | Taps the center of the matched element. |
| `inputText` | string | Types text into the focused input field. |
| `pressKey` | key name | Presses a keyboard key (`Enter`, `Backspace`, `Escape`, …). |
| `swipe` | `{ from, to, durationMs? }` | Swipes between two points. Coordinates are 0–1. `durationMs` is milliseconds as a number, default 300, max 2147483647. Fractional values (e.g. 250.5) are accepted. |
| `scroll` | keyword or `scroll: ` | Scrolls the screen. The bare keyword scrolls down. |
| `openUrl` | URL string | Opens a deep link or URL. |
| `assertVisible` | selector | Waits until the element appears; fails if it does not. |
| `assertNotVisible` | selector | Waits until the element disappears; fails if it does not. |
There is no fixed `sleep` step. Waiting is always condition-based. To wait for a transition, give `assertVisible` a `timeout`.
### Selectors
`tapOn`, `assertVisible`, and `assertNotVisible` target an element with a selector, which takes three forms.
```yaml
# a single string — resolved in the order below
- tapOn: "Sign in"
# explicit identifier
- tapOn: { id: "com.example.app:id/login" }
# explicit label + wait time (seconds)
- tapOn: { label: "Sign in", timeout: 20 }
# narrow a shared label by element kind
- tapOn: { label: "New Orders", role: button }
# a label-less row: pick the Nth of its kind (0-based)
- tapOn: { role: cell, index: 2 }
```
A bare-string selector resolves in the order **exact identifier → exact label → partial label**. Once a stage matches, later stages are not tried.
If a `tapOn` selector matches more than one element, it fails immediately and lists the candidates. It never picks the first one implicitly, so an ambiguous selector fails visibly instead of quietly tapping the wrong place. `assertVisible` checks presence only, so it passes when at least one element matches.
When a `tapOn` fails because its selector matched nothing or more than one element, the runner prints up to three lines to try instead, taken from the screen at the moment it failed: `try: tapOn: "Sign in"`. Each one is a selector checked to tap exactly that one element on that screen, and it is kept in the [run record](#run-records) too. An input field is suggested by its `id`, never its label, so what was typed into it is not shown. An image is suggested only when its name matches the selector's, ignoring case, spacing and punctuation, because an image's name is usually an asset name rather than text on the screen. A failed `assertVisible` gets no suggestions: the expected text being missing can mean the app changed, and swapping in similar text would hide the regression.
To resolve an ambiguous match, add a disambiguator to the object form:
- `role` — narrow by element kind (`button`, `text`, `cell`, `input`, …), e.g. when a button and its inner text share a label.
- `index` — 0-based; pick the Nth remaining match. Useful for label-less, id-less rows where `role` alone still matches several.
The object form needs at least one of `id`, `label`, or `role`; `role`/`index` refine that set.
`timeout` is in seconds and defaults to 10 (max 2147483.647, about 24.8 days). You can set it per selector. Values that round to 0ms (e.g. 0.0004s) are rejected.
### Resetting state
`clearState` wipes the app data so every run starts from the same state. It uses `pm clear` on Android and empties the app data container on iOS. The installed binary survives either way, so you can follow `clearState` with `launchApp`.
If the app is already running, `launchApp` does not restart it: it brings the app forward on whatever screen it was last on. A flow can then start from a screen an earlier run or a manual test left behind, so a flow that must start from the app's first screen puts `clearState` before `launchApp`. `clearState` stops the app before wiping its data.
```yaml
# uses the top-level appId
- clearState
# names a different bundle
- clearState: com.other.app
```
## Running flows
```sh
tapflow flow run .tapflow/flows/login-smoke.yaml
```
You can pass several files at once.
```sh
tapflow flow run .tapflow/flows/login.yaml .tapflow/flows/checkout.yaml
```
| Option | Description |
|--------|-------------|
| `--relay ` | Relay URL (default `ws://localhost:4000`) |
| `--token ` | Personal access token (PAT) for a remote relay (or the `TAPFLOW_TOKEN` env var, or the [token file](/automation/mcp-server#token-file)). Use an API-type token from the dashboard (`view, builds:write`) |
| `--device ` | Target device by name. Boots it when shut down. |
| `--session ` | Session id of the target device. Use it when several devices share a name and `--device` cannot pick one. |
| `--build ` | Build under test. Installed before the run; the `launchApp` step launches it. |
| `--no-install` | Skip installing the `--build` build before the run. Use it to re-run against a build that is already installed. |
| `--junit ` | Write a JUnit XML report to this path. |
| `--artifacts ` | Failure-screenshot directory (default `.tapflow/artifacts`) |
| `--timeout ` | Default per-selector wait (default 10, max 2147483.647) |
| `--no-record` | Do not record this run on the relay. |
The `launchApp` step takes no argument and launches the build passed via `--build`. That keeps the build id out of the flow file, so the same flow runs against a fresh build on every CI run.
When the run starts, the runner prints a dashboard link on a `watch this run:` line. Open it to watch, live, the flow driving the device; the watch ends when the run does. In CI the line is not printed (detected from the `CI` environment variable, Jenkins's `JENKINS_URL` or Azure Pipelines' `TF_BUILD`), because the link shows the relay's address and would stay in a public repository's log. How the address is chosen, and who can watch, are the same as for the [MCP server](/automation/mcp-server#watch-the-device), with the `--relay` address in place of `TAPFLOW_RELAY_URL`.
### Run records {#run-records}
With a token, the runner records the run on the relay: each flow's pass or fail and steps, a screenshot of the screen each flow failed on or ended on, the device it ran on, and the build passed with `--build`. In CI it also records the provider, branch, commit and a link to the CI run. When the relay created the record, the runner prints its id on a `recorded as run` line at the end of the run, followed outside CI by a link to the run's dashboard page. The records are on the dashboard's [Flow Runs](/automation/runs) page.
- Recording never changes the result or the exit code. If the relay does not answer or refuses the record, the runner prints one warning line and records nothing more for that run. A screenshot the relay cannot take (for example, one over 5 MB) gets its own warning, and the rest of the run is still recorded. At the end of a run it waits at most 10 seconds for the record to finish sending. Capturing a passed flow's last screen takes one screenshot before the next flow starts, at most 5 seconds.
- While recording, the runner also sends each flow's video. It cuts the device stream it already receives, so a flow never waits on the video being sent. On iOS it asks the agent for an H.264 stream for this, and when the agent is set to send JPEG (`TAPFLOW_IOS_CODEC=jpeg`) no video is kept. If video cannot be sent, the runner prints one warning line and the record carries on. At the end of a run the record is sent first, and video gets what is left of the 10 seconds. The relay keeps a passed flow's video for 3 days by default, and when all videos together pass 20 GB it removes passed flows' videos first, oldest first.
- Recording needs a token with the `builds:write` scope whose owner is not a Viewer. An API-type token works.
- A run without a token against a relay on the same Mac is not recorded, and one line says so.
- A run's record is deleted with its build; a record of a run with no build is deleted after 7 days.
### Exit codes
The exit codes are a contract so CI can tell what happened.
| Code | Meaning |
|------|---------|
| `0` | All flows passed |
| `1` | At least one product failure, including a run with both product and environment failures |
| `2` | Environment/config error before execution, or every failed flow was environmental |
| `130` | Cancelled by `SIGINT` (Ctrl+C, or a cancelled GitHub Actions job) |
| `143` | Cancelled by `SIGTERM` (what `kill` sends by default) |
Distinguishing `1` from `2` matters: a product failure (`1`) and an infrastructure problem (`2`) should be handled differently on a CI dashboard. A run with both kinds stays at `1` so an infrastructure blip cannot hide a regression.
A run cancelled while its flows are running leaves its session and, when it is being recorded, marks the record cancelled before it exits. A second signal exits at once.
A failed flow leaves a screenshot from the point of failure in the artifacts directory, and with `--junit` each flow is recorded as one `testcase`.
## File location convention
Keep flow files in your app repository under `.tapflow/flows/`. The runner accepts any path, so this is not enforced, but the CI examples and docs default to it.
```text
your-app/
├── .tapflow/
│ └── flows/
│ ├── login-smoke.yaml
│ └── checkout.yaml
└── ...
```
A JSON Schema for editor autocomplete ships with the `@tapflowio/flow-runner` package at `schema/tapflow-flow.schema.json`.
## Running in CI
With a relay and agent always on a self-hosted Mac runner, a CI job only has to replay the flows. No LLM is involved on the replay path, so there is no API cost.
```yaml
name: Flow smoke test
on:
workflow_dispatch:
push:
branches: [main]
jobs:
smoke:
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Run flows
env:
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
TAPFLOW_TOKEN: ${{ secrets.TAPFLOW_TOKEN }}
run: |
tapflow flow run .tapflow/flows/*.yaml \
--relay "$TAPFLOW_RELAY_URL" \
--device "iPhone 16 Pro" \
--build "$BUILD_ID" \
--junit report.xml
- name: Publish report
if: always()
uses: actions/upload-artifact@v4
with:
name: flow-results
path: |
report.xml
.tapflow/artifacts/
```
The exit-code contract makes the job fail when a step fails (`1` or `2`). `if: always()` collects the failure screenshots and the JUnit report even on a failing run.
## Relationship to run_flow
The same flow engine runs through the MCP `run_flow` tool. A coding agent authors a scenario once while exploring the app, then replays that flow deterministically afterward. Exploratory work goes through the individual MCP tools; verified scenarios replay through `run_flow` — a hybrid of the two.
Authoring by an agent and replaying with a deterministic runner is covered in more depth in [MCP in CI/CD](/automation/mcp-ci).
---
# https://www.tapflow.dev/automation/mcp-ci
# MCP in CI/CD
::: warning Experimental
tapflow's **AI Automation axis** — the MCP server and the flow runner — is experimental. The manual QA dashboard is the mature, production path; this axis is additive and still maturing. Expect rough edges, especially in selector matching and in timing right after an app launches.
:::
The automated QA axis splits into two stages: an LLM agent explores the app and **authors** a scenario, and a deterministic runner **replays** it. The guiding principle is simple — an LLM is involved only at authoring time, and replay is deterministic.
That distinction changes CI. If an LLM judges the screen on every run, the result varies run to run and you pay for API calls each time. Save a verified scenario as a [flow](/automation/flows) instead, and CI replays that flow with no LLM calls — idempotent, and free.
## Deterministic replay — the main CI path
A CI job replays saved flows with `tapflow flow run`. There is no LLM on the replay path, so the same input always produces the same result, and there is no API cost.
With a relay and agent always on a self-hosted Mac runner, the job runs the flows and uses the exit code to tell whether they passed.
```yaml
name: Flow smoke test
on:
workflow_dispatch:
push:
branches: [main]
jobs:
smoke:
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Run flows
env:
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
TAPFLOW_TOKEN: ${{ secrets.TAPFLOW_TOKEN }}
run: |
tapflow flow run .tapflow/flows/*.yaml \
--relay "$TAPFLOW_RELAY_URL" \
--device "iPhone 16 Pro" \
--build "$BUILD_ID" \
--junit report.xml
```
The flow YAML syntax, selector rules, and exit-code contract are covered in the [Flow Reference](/automation/flows).
## Authoring flows with a coding agent {#authoring-flows-with-an-agent}
Flows are an artifact an agent produces while exploring the app, not something you hand-write from scratch. Ask an MCP-capable agent like Claude Code for a scenario in plain language, and it drives the app through tapflow's MCP tools to confirm the behavior, then extracts the verified sequence as flow YAML and commits it to the repository.
To connect the coding agent to the relay, keep a `.mcp.json` at the repo root pointing at the relay.
```json
{
"mcpServers": {
"tapflow": {
"command": "tapflow-mcp",
"env": {
"TAPFLOW_RELAY_URL": "ws://localhost:4000",
"TAPFLOW_TOKEN": "INJECTED_AT_RUNTIME"
}
}
}
}
```
Give the agent a request that describes the outcome rather than the steps.
```text
Build a flow that signs in with an email and password and confirms the orders list.
Drive the app to verify it works, then save the verified sequence to .tapflow/flows/login-smoke.yaml.
```
The agent reads screen elements with `query_ui_tree`, drives them with `tap` and `type_text` to confirm the scenario, then saves it as a selector-based flow. That flow then replays deterministically in CI.
## Replaying verified scenarios with run_flow
You can use deterministic replay from within an agent session too. The `run_flow` tool replays a saved flow through the same deterministic engine, so an agent goes through the individual MCP tools while exploring and replays a verified scenario through `run_flow` — a hybrid of the two.
## Exploratory runs — optional
When you are exploring a new screen or debugging a flow, you can let an LLM judge the screen directly in CI. This path reads the screen with an LLM on every run, so it is not deterministic and it costs API calls, but it is useful for quickly checking a scenario you have not yet pinned into a flow.
This path needs a few more prerequisites.
| Requirement | Notes |
|-------------|-------|
| tapflow relay (always-on) | A Mac with the agent connected. Can be a dedicated Mac mini on your LAN. |
| `TAPFLOW_TOKEN` | An **API**-type personal access token (PAT, `view, builds:write`), created in **Settings → Tokens** (shown to Admins, Developers and QA). Store as a CI secret. |
| `ANTHROPIC_API_KEY` | Required to run `claude` non-interactively. Store as a CI secret. |
| Claude Code CLI | `npm install -g @anthropic-ai/claude-code` |
```yaml
- name: Exploratory check
env:
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
TAPFLOW_TOKEN: ${{ secrets.TAPFLOW_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
WORKSPACE: ${{ github.workspace }}
run: |
claude --mcp-config .mcp.json -p "
List available devices and connect to a booted iOS simulator.
Install the build at $WORKSPACE/MyApp.app.zip and launch the app.
Take a screenshot and verify the main screen loaded correctly.
If there are error messages or blank screens, describe the issue and exit with a failure.
"
```
Exploratory prompts work best when they describe outcomes. "Verify the home screen loaded" holds up better than "tap the third item in the list" when the UI changes. For a regression test you run repeatedly, though, pinning the confirmed scenario into a flow is deterministic and free.
## Tips
- **Flows for regression, coding agents for exploration.** A test that runs every time in CI fits deterministic replay; checking a new scenario is faster with an agent.
- **The `env` values in `.mcp.json` are overridden at runtime by the shell environment**, so secrets never land in the repo.
- **One session per device.** The relay routes by session, so running multiple jobs against the same relay concurrently is fine as long as they connect to different devices.
---
# https://www.tapflow.dev/automation/mcp-server
# MCP Server
::: warning Experimental
tapflow's **AI Automation axis** — the MCP server and the flow runner — is experimental. The manual QA dashboard is the mature, production path; this axis is additive and still maturing. Expect rough edges, especially in selector matching and in timing right after an app launches.
:::
`@tapflowio/mcp-server` exposes tapflow as a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server. Claude Code, Codex, and any other MCP-compatible LLM agent can control iOS simulators and Android emulators as native tools — no scripting, no hardcoded selectors.
These three guides fit together: connect a coding agent here, learn the flow YAML format in the [Flow Reference](/automation/flows), then combine them in [MCP in CI/CD](/automation/mcp-ci), where an agent authors a flow once and CI replays it deterministically.
## When to use this
**Repeatable automated testing** is where this shines. One-off manual checks are still faster done by hand.
- **CI/CD regression tests** — After each build, an agent boots a simulator, installs the build, walks through key flows, captures screenshots, and reports regressions. No human intervention needed. → [MCP in CI/CD](/automation/mcp-ci)
- **Multi-device matrix** — Run the same flow on iPhone SE (iOS 16), iPhone 15 Pro (iOS 17), and an Android emulator in sequence without manually switching devices.
- **Natural language QA scripts** — Non-developers (QA, PM) describe test scenarios in plain text; the agent executes them. No coordinate mapping or brittle selectors.
## How it connects
```text
LLM Agent (Claude Code, etc.)
↓ MCP protocol (stdio)
@tapflowio/mcp-server
↓ WebSocket + REST
tapflow relay
↓ WebSocket
Mac Agent (iOS · Android)
```
The MCP server is a local process that bridges the LLM agent to your self-hosted relay. App data never leaves your network.
## Prerequisites
- A running tapflow relay.
- A **personal access token (PAT)** created in the dashboard.
Go to **Settings → Tokens → New token** and choose the **API** Type. The Tokens page is shown to Admins, Developers and QA.
## Installation
```sh
npm install -g @tapflowio/mcp-server
```
## Setup
### Claude Code
Register tapflow with the `claude mcp add` command:
```sh
claude mcp add tapflow --scope project \
--env TAPFLOW_RELAY_URL=ws://localhost:4000 \
--env TAPFLOW_TOKEN=tflw_pat_your_token_here \
-- tapflow-mcp
```
`--scope project` saves the config to `.mcp.json` so the whole team shares it. Use `--scope local` (the default) if you only want it for yourself.
If the relay is on a remote server, change the URL:
```sh
claude mcp add tapflow --scope project \
--env TAPFLOW_RELAY_URL=wss://your-relay.example.com \
--env TAPFLOW_TOKEN=tflw_pat_your_token_here \
-- tapflow-mcp
```
### Other MCP clients (Cursor, VS Code, Codex)
Any MCP-compatible client can use tapflow. Add the following to your MCP config JSON:
```json
{
"mcpServers": {
"tapflow": {
"command": "tapflow-mcp",
"env": {
"TAPFLOW_RELAY_URL": "ws://localhost:4000",
"TAPFLOW_TOKEN": "tflw_pat_your_token_here"
}
}
}
}
```
## Environment variables
| Variable | Description | Default |
|----------|-------------|---------|
| `TAPFLOW_RELAY_URL` | Relay WebSocket URL | `ws://localhost:4000` |
| `TAPFLOW_TOKEN` | An **API**-type PAT (`view, builds:write`), created in **Settings → Tokens**. Leave it out to read the [token file](#token-file) | (none) |
| `TAPFLOW_TOKEN_FILE` | Path of the token file | `~/.config/tapflow/credentials.json` |
## Keep the token in a file {#token-file}
Without `TAPFLOW_TOKEN`, the MCP server reads its token from a file. `tapflow token set` is the simplest way to store one, and it checks the token with the relay first; on a computer without the `tapflow` CLI, write the file by hand in the shape below. When the relay refuses the token, the server reads the file again and retries once, so replacing a token needs no re-registration and no restart.
- The file is `~/.config/tapflow/credentials.json`, or `tapflow/credentials.json` under `XDG_CONFIG_HOME` when that is set, or the path in `TAPFLOW_TOKEN_FILE`. It is in the home directory of the computer that runs the MCP server, not of the Mac the relay runs on.
- Each relay has its own token, keyed by the relay address's origin; write `ws://` as `http://` and `wss://` as `https://`.
- The file holds a token, so keep it at mode `600`, readable only by you. The MCP server warns when other users can read it (not checked on Windows).
```json
{
"version": 1,
"relays": {
"http://localhost:4000": { "token": "tflw_pat_your_token_here" }
}
}
```
`TAPFLOW_TOKEN`, when set, wins over the file and is never replaced by it. To move a registered `TAPFLOW_TOKEN` into the file, remove it from the MCP registration.
When a token is revoked, the relay closes the connection right away. Write the new token to the file and the next tool call reconnects with it. The relay releases the sessions the old connection held, so call `connect_device` again.
Connected straight to the relay's port on the Mac it runs on, the MCP server starts and drives devices without a token. `screenshot`, `query_ui_tree`, `list_builds`, `list_runs` and `get_run` still need one. When the relay refuses a token, the error says which relay refused it, where the token came from, and how to fix it.
## Available tools
| Tool | Description |
|------|-------------|
| `list_builds` | List the apps and builds on the relay (where the `buildId` for `install_app` and `launch_app` comes from) |
| `list_devices` | List connected simulators and emulators |
| `connect_device` | Join a session (required before controlling a device). Also returns `watchUrl`, a dashboard link for watching the device |
| `disconnect_device` | End a session |
| `boot_device` | Boot a simulator or emulator |
| `shutdown_device` | Power the device down (frees resources; forces a cold boot next time) |
| `screenshot` | Capture the current screen (PNG, or JPEG on iOS when requested) |
| `query_ui_tree` | Read the on-screen UI as a structured accessibility tree (role, label, identifier, frame) |
| `tap` | Tap at a coordinate |
| `swipe` | Swipe between two coordinates |
| `type_text` | Type text into the focused field |
| `press_key` | Press a keyboard key |
| `press_button` | Press a hardware button (home, lock, etc.) |
| `install_app` | Install an app |
| `launch_app` | Launch an installed app |
| `run_flow` | Replay a YAML flow deterministically (no further LLM calls) |
| `list_runs` | List the run records `tapflow flow run` left on the relay (filter by status, build, flow name, CI or local) |
| `get_run` | Read one run record: each failed flow's steps (a passed flow's step count), failure message and file path, and screenshots of up to four failed flows |
## Typical workflow
An LLM agent typically calls tools in this order:
```text
list_devices → get available devices and sessionIds
connect_device → join a session, hand the person the watch link
boot_device → wait for the device to be ready (skip if already booted)
install_app → install the build
launch_app → launch the app
screenshot → capture screen → LLM analyzes
tap / swipe / ... → interact
screenshot → verify result → repeat
disconnect_device → end the session
```
::: info Device already booted
If `list_devices` returns `"status": "booted"` for a device, you can skip `boot_device`.
:::
For running this in a CI pipeline, see [MCP in CI/CD](/automation/mcp-ci).
## Watch the device a coding agent drives {#watch-the-device}
`connect_device` returns `watchUrl` in its result. Open that link in a browser to watch, live, the device the coding agent is driving. The tool description tells the coding agent to hand the link to the person who asked, so when you ask for something like "test the login screen", the coding agent gives you the link as it starts testing.
The page is reached only through the link. It is for the person who asked the coding agent to test, to watch their own work, so the dashboard has no list of these sessions. A teammate who opens the QA session's device list sees the device as in use, with **Coding agent is driving it** as the reason.
- Watching is read-only. Nothing you click reaches the device, and there is no sound.
- Any teammate signed in to the dashboard can watch. Whatever the coding agent types on the device shows on screen too, so do not test with values the team should not see.
- Up to 4 people can watch one session at a time.
- **Agent activity** beside the device shows what the coding agent does, in the order it happens, and a blue cursor stays on the device where it last tapped, gliding to the next spot with a brief trail. When a flow runs through `run_flow` or `tapflow flow run`, each step is a row with the actions taken in it underneath; steps start open and can be collapsed. Typing shows as a count of characters, and the URL opened or the character key pressed is not named. Nothing is stored, so the log starts when you open the page. When the session ends, the last picture and the log stay until you close the page.
- If the coding agent's connection drops briefly, the page keeps the last picture and waits for it to come back. The watch ends when the coding agent finishes with the session (`disconnect_device`) or another teammate starts using the device.
::: details How the link address is chosen
If the relay has an address teammates can open (a tunnel or `relay.url`), the link uses it, and you can share it with teammates as it is. Otherwise, including when `relay.url` is a `localhost` address, the MCP server builds the link from the `TAPFLOW_RELAY_URL` it connected with. For example, connected with `ws://localhost:4000`, the link starts with `http://localhost:4000/automation/sessions/` and opens on the computer running the MCP server. For an address to send to teammates, see [External access](/operate/external-access).
:::
## Fix a failed run {#fix-a-failed-run}
When `tapflow flow run` fails in CI, a coding agent can read the run and repair the flow. Ask it something like "fix last night's failed CI run", and it uses the tools in this order:
```text
list_runs → find the failed run (status: failed)
get_run → which step failed and why, the screen at that moment, and the flow file's path
(edit) → change the YAML at the recorded path
run_flow → replay the fixed flow on a device to check it
```
- A recorded flow file path is as the runner was given it, relative to the directory it ran in (the CI job's working directory, for a CI run). The coding agent finds that file in its own checkout.
- Run records are only read. The coding agent replays the flow itself with `run_flow`; nothing re-runs the CI job.
- `get_run` returns up to 4 screenshots of failed flows with the record. A passed flow's last screen and the videos are not included; open the run's page at the result's `url` to see them.
- A failure whose `failureKind` is `environment` was the device, the agent or the relay, not the app or the flow. There is nothing to fix in the flow.
The result's `url` is built from the `TAPFLOW_RELAY_URL` the MCP server connected with. Unlike the watch link, it does not use the relay's public address, so a server that connected by a name inside a container network (such as `ws://relay:4000`) gets a link a browser cannot open.
For when runs are recorded and what the dashboard shows, see [Flow Runs](/automation/runs).
---
# https://www.tapflow.dev/automation/runs
# Flow Runs
::: warning Experimental
The flow runner and its run records are part of tapflow's **AI automation axis**, which is experimental. The mature path is the manual QA dashboard.
:::
The dashboard's **Flow Runs** page shows the records `tapflow flow run` leaves. When CI runs your flows on every build, anyone on the team can see in a browser which flow failed on which build, and why. A failure in the app is kept apart from an environment problem such as the device, the agent or the relay.
A run is recorded when the runner has a token. See [Recording runs](#recording) to set it up.
## How to use {#how-to-use}
1. Open **Flow Runs** in the dashboard's sidebar. It is listed there once the first run is recorded, and opens on CI runs.
2. Pick where the runs came from with **All** / **CI** / **Local** at the top. You can narrow the list by status (**Any status**, **Running**, **Passed**, **Failed**, **Cancelled**) and by flow name; part of a name is enough.
3. Select a row to open that run. Cmd-click (Ctrl-click on Windows and Linux) opens it in a new tab.
The list shows 25 runs a page, newest first; **Newer** and **Older** below it move between pages.
The filters are part of the address, so a filtered list is a link you can share.
### A run's page {#run-detail}
A run's page lists its flows in the order they ran.
- A failed flow shows its failure message, each step's result and a screenshot from the moment it failed. A passed flow shows the screen it ended on, as the evidence that it passed: a login flow shows the screen it logged in to. Select a screenshot to open it full size.
- A `tapOn` step that failed because it found no element, or several, lists lines to try instead under **Try instead**, from the screen at the moment it failed. Each line has a **Copy** button. [Flows](/automation/flows#selectors) explains how they are chosen.
- A flow the run never reached is marked **Not run**.
- **Open the CI run** opens the run on the CI side when the CI recorded a link to it. For a run with a build, **Other runs of this build** lists the same build's other runs.
### Watch a flow's video {#flow-video}
A flow the runner kept a video of has a **Play the video** button. It downloads the whole flow, start to end, and plays it in the same device frame the live view draws. The screenshot shows the screen a flow ended on; the video shows how it got there. Nothing is downloaded until you select the button.
- The dots above the bar mark where each step started, red where one failed. A grey bar is a stretch that never reached the relay. Hover over a dot or bar to see what it is, and select it to go there.
- Dragging the bar catches on each step's start. Playback speed goes from 0.5× to 4×.
- A flow with no video says why: the agent sent JPEG, the device's stream had no frame a video could start from, or the video is past its retention window.
- A video that ends early says why too: it reached the 100 MB limit for one flow, video storage was full, the run was cancelled, the runner disconnected, or the end of the video never reached the relay.
### Watch a run in progress {#watch-a-run}
While a run is in progress, the list and the run's page refresh every few seconds. **Watch the device** on the run's page shows the device the runner is driving, live and read-only: nothing you click reaches the device. Up to 4 people can watch a session.
## What the statuses mean {#statuses}
| Status | Meaning |
|--------|---------|
| **Running** | In progress |
| **Passed** | Every flow passed |
| **Failed** | At least one flow failed on the app |
| **Environment problem** | Every failure was the environment (the device, the agent or the relay), not the app. A run that failed before any flow started is shown here too, and its page gives the reason. Shown in gray rather than red so a regression stands out. The Status filter counts it under **Failed**. |
| **Cancelled** | The run was cancelled (Ctrl+C, a cancelled CI job). A run whose runner disconnected before saying it was over is shown here too, and its page says so. |
## Recording runs (operator) {#recording}
- Give `tapflow flow run` an API-type token (`view, builds:write`) and every run is recorded. Pass it with `--token` or `TAPFLOW_TOKEN`, or keep it in the [token file](/automation/mcp-server#token-file). The details are in the [Flow reference's run records](/automation/flows#run-records).
- Add `--no-record` to skip recording.
- Video retention is set in the **Flow run videos** card of **Settings → Default** (Admin): how many days passed flows' videos are kept and how much storage videos may use, with what is in use now. The defaults are 3 days and 20 GB. A failed flow's video is kept as long as its run unless storage runs out: when storage is full the oldest passed videos go first, and failed ones only after every passed one has gone.
- When you run it locally, the runner prints a link to the run's page when it ends. In CI it prints the run's id only, so the relay's address stays out of the log.
- A run's record is deleted with its build; a record of a run with no build is deleted after 7 days.
## Related {#related}
- [Flow reference](/automation/flows): writing flows and the `tapflow flow run` options
- [MCP in CI/CD](/automation/mcp-ci): running flows in CI
- [Runs in the REST API](/reference/api#runs): the endpoints that write and read run records
---
# https://www.tapflow.dev/contributing
# Contributing
Contributions are welcome. See [`CONTRIBUTING.md`](https://github.com/jo-duchan/tapflow/blob/main/CONTRIBUTING.md) in the repository for the full contributing guide — development setup, branch strategy, test principles, versioning, and commit conventions.
## Project structure
```
packages/
agent-core/ ← shared DeviceAgent interface
ios-agent/ ← IOSAgent (macOS)
android-agent/ ← AndroidAgent (macOS)
relay/ ← relay server + REST API + SQLite
dashboard/ ← React SPA (served by relay)
cli/ ← tapflow CLI
docs/ ← this documentation site (VitePress)
playground/ ← local integration test environment
```
## Reporting bugs
Use the [Bug Report](https://github.com/jo-duchan/tapflow/issues/new?template=bug_report.yml) issue template. Include steps to reproduce, expected vs. actual behavior, and your environment (tapflow version, Node.js version, and Xcode version for iOS issues).
---
# https://www.tapflow.dev/get-started/introduction
# Introduction
**tapflow** lets your entire team run mobile QA directly in the browser — no developer tools, no device management, no external cloud.
## Why tapflow?
| Solution | Problem |
|----------|---------|
| Appetize / BrowserStack | Expensive, app data leaves your network |
| Physical devices | Cost, loss, management overhead |
| Xcode / Android Studio directly | Every team member needs their own Mac + Xcode or Android Studio setup |
| tapflow | Use infra you already own, data stays on-prem |
In short, tapflow is a self-hosted, open-source alternative to cloud testing services like Appetize and BrowserStack App Live — the same browser-based mobile QA, but builds and test data stay on infrastructure you already own.
## Operators and teammates {#who-does-what}
Two kinds of people use tapflow. One operator installs it; everyone else installs nothing.
| Who | What they do | Start here |
|---|---|---|
| Operator | Installs and runs tapflow on a Mac. Creates the admin account, uploads builds and invites the team. | [Quick Start](/get-started/quick-start) |
| Teammates (PO, PM, designers, backend engineers, QA) | Open the address the operator sent, sign in and test builds in the browser. | [For teammates](/get-started/teammates) |
## Key concepts
- **Relay** — the server between the dashboard and the agents. It serves the dashboard, stores accounts and uploaded builds, and carries the device screen and your input between the browser and an agent. A team runs one.
- **Agent** — the tapflow process on a Mac that runs iOS simulators and Android emulators and sends their screens to the relay. In these docs "agent" always means the tapflow agent; tools like Claude Code are called "coding agents".
- **Dashboard** — the tapflow screens you open in a browser. The relay serves it, so there is nothing separate to deploy.
- **App Center** — the build list inside the dashboard. It has nothing to do with Microsoft App Center. Builds are grouped by app, and an app is a bundle ID on a platform.
- **Build** — the app file you test: a simulator `.app.zip` or `.tar.gz`/`.tgz` for iOS, an `.apk` for Android.
- **QA Session** — the screen where you pick a Mac and a device for a build from App Center and drive the device from your browser.
- **Role** — what an account may do: Admin, Developer, QA or Viewer. See [Team, roles & tokens](/operate/team-and-roles).
## How it works
The simplest setup is one Mac. `tapflow start` runs the relay and an agent together on that Mac, and teammates open its address in a browser.
```mermaid
flowchart LR
B["Teammate's browser"]
subgraph M["One Mac (tapflow start)"]
R["Relay"]
A["Agent
iOS simulators · Android emulators"]
end
B <-->|HTTP · WebSocket| R
R <--> A
```
As the team grows, the relay runs on its own host and more agent Macs connect to it.
```mermaid
flowchart TD
B["Browser (Team)"]
R["Relay host
Mac or Linux, same LAN"]
A1["Agent Mac 1
iOS · Android"]
A2["Agent Mac 2
iOS · Android"]
More["Agent Mac N
iOS · Android"]
B <-->|WebSocket| R
R <-->|WebSocket outbound| A1
R <-->|WebSocket outbound| A2
R -.->|WebSocket outbound| More
```
1. An agent connects outbound to the relay, so an agent Mac needs no inbound firewall rules.
2. Teammates open the dashboard in a browser, pick a build in App Center and start a QA Session.
3. Clicks and keystrokes reach the device in real time; the screen streams back to the browser.
::: info Streaming format by platform
- **iOS** Simulator: H.264 stream (~30 fps; JPEG fallback on older browsers)
- **Android** Emulator: H.264 stream (~30 fps)
Visual quality and latency may differ between the two. Resolution and decoder adapt to each viewer's connection — see [Streaming Quality](/operate/streaming-quality).
:::
## Two testing paths {#testing-paths}
tapflow is built for manual testing first: CI or the operator uploads a build, and teammates test it in the browser. The Get started and Test apps sections cover this path.
There is also an MCP server that lets a coding agent drive the simulator. It is a separate, experimental feature, and the manual path does not depend on it. See [MCP server](/automation/mcp-server).
## Next steps {#next-steps}
- [Quick Start](/get-started/quick-start): install tapflow on a Mac as the operator and follow it through to the first tap on your build.
- [For teammates](/get-started/teammates): start here if someone on your team sent you a tapflow address or invite link.
---
# https://www.tapflow.dev/get-started/quick-start
# Quick Start
This page takes you from installing tapflow on one Mac to tapping your own build's screen in the browser. The last step invites a teammate to test the same build. If someone on your team sent you an invite link, you have nothing to install — go to [For teammates](/get-started/teammates).
::: info Before you begin
- **An Apple Silicon (M-series) Mac**: Intel Macs are not supported. See [Requirements](/operate/requirements) for the supported macOS and Xcode versions.
- **Node.js 22 or later**
- **The Mac's administrator password**: `tapflow setup` asks for your sudo password when it installs Homebrew and the JDK, and while it finishes setting up Xcode.
- **An Apple account**: you sign in with one to download Xcode, from the App Store or from Apple's developer site.
- **A build to test**: a simulator `.app.zip` or `.tar.gz`/`.tgz` for iOS, an `.apk` for Android. A device `.ipa` cannot be uploaded. An Android APK needs the `arm64-v8a` ABI to install on an Apple Silicon emulator. If you have no build yet, ask your app's developers for a simulator or emulator build; [Upload from CI](/operate/ci-distribution) shows how CI can produce and upload one.
- **Time**: most of it is `tapflow setup` downloading. Xcode, the simulator runtime, the Android SDK and its system image are large, so a first download takes as long as your network needs. When they are already installed, setup only checks them.
:::
## 1. Install tapflow {#_1-install-tapflow}
::: code-group
```sh [npm]
npm install -g tapflow
```
```sh [yarn]
yarn global add tapflow
```
```sh [pnpm]
pnpm add -g tapflow
```
:::
## 2. Set up the environment {#_2-set-up-the-environment}
On the Mac that will run the simulators and emulators, install what they need in one step:
```sh
tapflow setup
```
Setup asks before each step that installs something. Setup cannot install Xcode itself, so it opens the App Store for you; press Enter once Xcode is installed and it carries on. Xcode is also available from [Apple's developer site](https://developer.apple.com/xcode/). During the iOS steps, macOS may show two prompts:
- **Audio recording permission**: needed to send the device's sound to the browser. Click **Allow**.
- **System extension approval**: the network filter used for iOS network control is a system extension, and macOS asks you to approve it. Without the approval setup ends with `SETUP INCOMPLETE` and names what is left. Everything except network control works without it.
To set up one platform only, run `tapflow setup ios` or `tapflow setup android`.
When setup finishes, run `tapflow doctor` to confirm. See [Environment Setup](/operate/environment-setup) for what each step does.
## 3. Configure tapflow (optional) {#_3-configure-tapflow-optional}
Skip this step if the defaults are fine: port 4000, no tunnel, HTTP. Run it only when you need a tunnel for access from outside the office, or HTTPS.
```sh
tapflow init
```
It asks about a tunnel, streaming performance (HTTP or HTTPS) and Lean mode. For what each question sets and where the configuration is written, see [Configuring tapflow](/operate/configure).
## 4. Start the relay and agent {#_4-start-the-relay-agent}
On the same Mac, run:
```sh
tapflow start
```
It prints the install, config and data paths first, then a banner like the one below once the relay and agent are ready. If an Android environment is present, an `android` agent connects too. If the first agent fails to connect, `start` stops with an error banner; an agent that fails after another has connected is reported on a ⚠ line and the rest keep running.
```text
→ Relay started on http://localhost:4000
✓ Connecting ios agent…
┌─────────────────────────────────────────────┐
│ ✓ TAPFLOW READY │
└─────────────────────────────────────────────┘
Relay : http://localhost:4000
Open http://localhost:4000 in your browser.
Press Ctrl+C to stop.
```
Leave this terminal open. `Ctrl+C` stops the relay and the agent together.
::: tip Running the relay on another server
Use `tapflow relay start` and `tapflow agent start`. See [Deployment options](/operate/deployment).
:::
## 5. Create the admin account {#_5-create-the-admin-account}
tapflow has no default account. The first account gets the Admin role, and you create it in a browser on this Mac.
1. On this Mac, open `http://localhost:4000` in a browser. With no accounts yet, you land on the **Set up tapflow** page (`/setup`).
2. Enter **Admin email**, **Password** (at least 8 characters) and **Confirm password**.
3. Click **Create admin account**. You are taken to the sign-in page.
On the sign-in page, enter the email and password you just created and click **Sign in**. App Center opens.
::: warning The first account can only be created on this Mac
Opened from another computer, the page shows no form and tells you to run `tapflow admin init` on this Mac instead. The setup page appears only while no account exists; every later account comes from an invitation.
:::
::: tip A server without a browser
Run `tapflow admin init` in a terminal; it asks for an email and a password and creates the admin account. The relay must be running first. For an install nobody can type into, set `TAPFLOW_ADMIN_EMAIL` and `TAPFLOW_ADMIN_PASSWORD` and the relay creates the account as it starts. See [Configuration](/reference/configuration#create-the-first-admin-account-in-a-docker-container-tapflow-admin-email).
:::
## 6. Upload your build {#upload-your-build}
1. In App Center, click **Upload build** at the top right.
2. Click the **File** area to choose your build, or drop the file onto it.
3. Click **Upload**. A **Build uploaded** notice appears and the build joins the list.
tapflow reads the bundle ID, version and build number from the file and creates the app entry for you. For review status, adding an app before its first build, and uploading from CI, see [App Center](/testing/app-center#upload-a-build).
## 7. Start a QA Session {#start-a-session}
1. On the build you just uploaded, click **Start QA**.
2. On the **Select Mac** page, pick this Mac's card.
3. On the **Select device** page, click a device. An **Available** device boots when you pick it.
Once the device is up, the build installs on its own. The info card beside the device shows **Starting device…**, then **Installing app…**. See [QA Session](/testing/qa-session) for each page in detail.
## 8. Tap the device {#first-tap}
When the install finishes, a **Launch app** button appears in the toolbar. Click it to open your app.
Now a click on the device screen is a tap, and a drag is a swipe. To type, click the device screen once and then type. If your app responds, the setup is done. See [Device controls](/testing/device-controls) for everything else you can do.
## 9. Invite a teammate {#invite-a-teammate}
Teammates install nothing. One invite link is all they need to sign up and test in their browser.
1. In the dashboard sidebar, open **Team** under **Settings**. Only Admins see it.
2. Click **Invite member**, then set **Email** and **Role**. The role defaults to **QA**.
3. Click **Generate invite link**. The link appears in the dialog.
4. Send it to your teammate over chat or email. It is valid for 7 days.
When you opened the dashboard at `http://localhost:4000` on this Mac, the invite link carries this Mac's LAN address instead of `localhost`. The part before `/invite` (for example `http://192.168.0.10:4000`) is the dashboard address your teammate uses from then on. If you set up a tunnel with `tapflow init`, the address on the startup banner's `Public :` line goes in instead. Without a tunnel, a `relay.url` in your configuration file does.
A teammate outside your network needs a tunnel to reach the relay; see [External access](/operate/external-access). What each role may do, and managing members, is in [Team, roles & tokens](/operate/team-and-roles#invite-teammates).
## Next steps {#next-steps}
- [For teammates](/get-started/teammates): how an invited teammate signs up and tests. Send it along with the link.
- [Test apps](/testing): everything a QA Session offers — deep links, network control, recordings, comments and more.
- [Upload from CI](/operate/ci-distribution): let CI upload builds instead of doing it by hand.
- [Deployment options](/operate/deployment): run the relay on its own host, or add more Macs.
- [Troubleshooting](/troubleshooting): when an install or a connection gets stuck, find the symptom here, along with your `tapflow doctor` output.
## Moved sections {#moved-sections}
The rest of the former First-time Setup page (`/dashboard/setup`) moved to these pages.
- [Team, roles & tokens](/operate/team-and-roles)
- [Invite your team](/operate/team-and-roles#_3-invite-your-team)
- [For teammates](/get-started/teammates)
- [Share access with your team](/get-started/teammates#_6-share-access-with-your-team)
---
# https://www.tapflow.dev/get-started/teammates
# For teammates
If someone on your team sent you a tapflow invite link, follow this page. You install nothing: the iOS simulators and Android emulators run on a Mac the operator set up, and you see and drive their screens in your browser. By the end you will have picked a build in App Center and tapped its screen yourself.
::: info Before you begin
- **A current browser**: Chrome, Firefox, Safari or Edge.
- **An invite link**: an Admin on your team creates it in the dashboard and sends it to you. The person who installed and runs tapflow (the operator) is the first Admin, but any member with the Admin role can invite. It is valid for 7 days from when it was created.
- **The Tailscale app** (only if the operator uses Tailscale): install Tailscale on your computer and join the same tailnet as the operator, or the dashboard will not open. See [Tailscale](/operate/external-access#tailscale-recommended).
:::
## 1. Accept the invite {#accept-the-invite}
1. Open the invite link in your browser. The **Set up your account** page shows the role the inviting Admin chose after **You're joining as**.
2. **Nickname** and **Avatar** are optional. Your nickname is the name shown on your comments; an avatar is a PNG or JPEG image of 2 MB or less.
3. Enter **Password** (at least 8 characters) and **Confirm password**, then click **Create account**. You are signed in and App Center opens.
If the invite page does not load, check your case in the [address table](#bookmark-the-address) below, or ask whoever runs tapflow.
If the link shows **Invitation expired**, it has expired or has already been used. Ask an Admin for a new one. See [Sign-in & accounts](/troubleshooting/accounts).
## 2. Save the dashboard address {#bookmark-the-address}
The dashboard address is the part of the invite link before `/invite`. If the link is `http://192.168.0.10:4000/invite?token=…`, the address is `http://192.168.0.10:4000`. Bookmark it; next time, open it, enter **Email** and **Password**, and click **Sign in**. A sign-in lasts 7 days.
What the address looks like depends on how the operator set tapflow up.
| Operator's setup | Address you open |
|---|---|
| Same office network | The operator's Mac on the LAN, e.g. `http://192.168.0.10:4000` |
| Tailscale | A tailnet address. Tailscale must be running on your computer too |
| VPS tunnel | A public address, e.g. `https://your-vps.com` |
An address with `localhost` in it only opens on the operator's own Mac. If that is what you received, ask the operator again.
## 3. Test a build {#test-a-build}
1. In App Center, pick the app in the **Apps** list on the left. The first app in the list is selected when the page opens, so check it when there are several.
2. Find the build to test. The **Search version…** box and the status filter narrow the list.
3. Click **Start QA** on the build's row. The button is off for a build whose review status is **Done**.
4. Pick a Mac on the **Select Mac** page, then click a device on the **Select device** page. A device a teammate is using shows **In use** and cannot be picked.
5. Once the device is up and the build has installed, click **Launch app** in the toolbar to open the app.
A click on the device screen is a tap, and a drag is a swipe. To type, click the device screen once and then type. See [Device controls](/testing/device-controls) for the rest.
## 4. Leave what you found {#leave-feedback}
Write what you found as a [comment](/testing/comments) on the build; everyone who opens the same build sees it. A comment can carry an image, and you can also keep the screen as a [screenshot or recording](/testing/screenshots-and-recordings).
## What each role can do {#roles}
Every account has the role the inviting Admin chose. Only an Admin can change it.
| Role | What it can do |
|---|---|
| Viewer | Views builds, tests them in a QA Session, and leaves comments and recordings. Apart from comments and recordings it is read-only: no uploading builds, changing review status or managing apps. |
| QA, Developer | Everything a Viewer does, plus uploading builds, changing review status, scheduling build deletion, adding, editing and deleting apps, and managing webhooks. The two roles have the same permissions. |
| Admin | Everything. Inviting members, changing their roles, removing them, resetting passwords, workspace settings and tokens are Admin-only. |
When a Viewer clicks **Upload build** or **Add App**, a notice says QA or Developer access is needed. For roles and member management in full, see [Team, roles & tokens](/operate/team-and-roles).
## Profile settings {#profile}
Click your name at the bottom of the sidebar and choose **Settings**.
- **Profile**: change **Nickname** and **Avatar**, then click **Save changes**.
- **Password**: enter **Current password**, **New password** and **Confirm new password**.
If you forget your password, ask an Admin. The Admin clicks **Reset pwd** in **Settings → Team** and sends you the reset link it shows. When tapflow can send email (SMTP is set up), the link also arrives by email. The link works once, for 2 hours. After the reset you are signed out everywhere, so sign in again with the new password. **Log out** is in the same menu.
## Next steps {#next-steps}
- [Test apps](/testing): every feature App Center and a QA Session offer.
- [QA Session](/testing/qa-session): picking a Mac and a device, the info card, and why a session ends.
- [App Center](/testing/app-center): the build list, review status, search and filters.
- [Sign-in & accounts](/troubleshooting/accounts): what to do when an invite or password-reset link has expired.
---
# https://www.tapflow.dev/operate/agents
# Agent Setup
The agent runs on a Mac and streams simulator and emulator screens to the relay. It connects outbound to the relay — no inbound firewall rules needed.
## Start the agent
When the agent and relay run on the same Mac, no flags are needed — the port is read from `tapflow.config.json` (default `4000`):
```sh
tapflow agent start
```
When the relay runs on a separate machine, pass its URL together with an auth token. `192.168.x.x` is the relay machine's LAN IP. See [Remote relay authentication](#remote-relay-authentication) for how to create the token:
```sh
tapflow agent start --relay ws://192.168.x.x:4000 --token tflw_pat_xxxxxxxx
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay` | `relay.url`, or `ws://localhost:[port]` | Relay WebSocket URL. Must start with `ws://` or `wss://`. When omitted, the agent uses `relay.url` from `tapflow.config.json` (or `TAPFLOW_RELAY_URL`), and falls back to `ws://localhost` on the configured port. If `relay.url` is an `http://` or `https://` address, the agent refuses to start, so pass `--relay` explicitly. |
| `--platform` | auto-detect | Platform to run: `ios`, `android`, or `all`. When omitted, every platform available on this Mac runs. |
| `--device` | all devices | Limit the agent to the devices whose name or ID matches. Works for iOS simulators and Android AVDs, and must match the name or ID exactly. |
| `--token` | none | Token with the `agent` scope for remote relay authentication. Can also be passed via the `TAPFLOW_AGENT_TOKEN` environment variable. |
::: tip Wired LAN recommended
Keep the agent and relay on the same wired LAN. Wi-Fi works but can stutter on a Mac (AWDL channel hopping), regardless of signal strength — see [Stream lag or stuttering](/troubleshooting/streaming#stream-lag) for why and how to mitigate.
:::
## Remote relay authentication
No authentication is needed when the agent connects to a relay on the same machine (`localhost`). When the relay runs on a different machine, it only accepts agents that present a token with the `agent` scope. This protects your sessions from an arbitrary device on the same network impersonating an agent and feeding screens into a test session.
The relay requires authentication on every connection except one that reaches the relay port over loopback — this section covers the agent side. Reaching the same Mac by its LAN address counts as remote, and so does a tunnel client, which connects from loopback on someone else's behalf and therefore gets a separate tunnel port where every connection has to authenticate. For how browsers reach the relay from outside the office (tunnels), see [External access](/operate/external-access).
### Create a token
In the dashboard, go to **Settings → Tokens → New token** and set the Type to **Agent**. Only accounts with the Admin role can create `agent`-scope tokens, and a token stops working once the member who issued it is no longer an Admin or is removed from the team. Agents connected with it are disconnected at that moment, and a current Admin has to issue a new token. The success screen shows a ready-to-run agent command — copy it and run it on the agent machine.
### Pass the token
Pass it with the `--token` flag:
```sh
tapflow agent start --relay ws://192.168.x.x:4000 --token tflw_pat_xxxxxxxx
```
If you prefer to keep the token out of your shell history, use the `TAPFLOW_AGENT_TOKEN` environment variable. When both are set, the flag wins:
```sh
export TAPFLOW_AGENT_TOKEN=tflw_pat_xxxxxxxx
tapflow agent start --relay ws://192.168.x.x:4000
```
Connecting to a remote relay without a token (or with an expired or revoked one) makes the agent print the rejection reason along with the token setup steps, then exit.
## iOS
### Prerequisites
An Apple Silicon Mac with the macOS and Xcode versions listed in [Requirements](/operate/requirements#ios), and the iOS Simulator runtime installed.
### List available simulators
```sh
tapflow devices
```
### Multiple simulators
Each Mac supports 2–4 simultaneous simulators depending on available RAM. The agent reports available slots automatically. See [Scaling Mac Resources](/operate/scaling) for details.
### Troubleshooting
```
✓ Node v22.x
✓ Port 4000
iOS
✓ Xcode 26.0
✓ xcrun simctl
✓ Simulator available (8)
…
```
## Android
### Prerequisites
The Android SDK and an AVD, as listed in [Requirements](/operate/requirements#android). `tapflow setup android` installs both.
### Create an AVD
`tapflow setup android` creates four AVDs by form factor (`tapflow-compact`, `tapflow-phone`, `tapflow-large`, `tapflow-tablet`) from the android-35 `google_apis/arm64-v8a` image. See [Environment Setup](/operate/environment-setup). To create one yourself, use Android Studio's AVD Manager; see [Create and manage virtual devices](https://developer.android.com/studio/run/managing-avds) for a step-by-step guide.
When you create an AVD yourself, note the following about the system image:
::: warning AVD image matters
Use a `google_apis/arm64-v8a` image — the tested and recommended configuration. The `google_apis_playstore` image is not tested and has shown H.264 encoder issues.
:::
The agent boots the emulator automatically, waits for `sys.boot_completed`, then begins streaming. For emulators on Apple Silicon, the agent encodes H.264 on the Mac host (VideoToolbox), capped at 30 fps by default (change it with `TAPFLOW_ANDROID_FPS`) — no GPU load on the emulator itself.
### Troubleshooting
```sh
tapflow doctor
# ✓ Node v22.x
# ✓ Port 4000
#
# Android
# ✓ Android SDK: /Users/you/Library/Android/sdk
# ✓ adb found: /Users/you/Library/Android/sdk/platform-tools/adb
# ✓ aapt (build-tools): /Users/you/Library/Android/sdk/build-tools/35.0.0/aapt
# ✓ AVD available: tapflow-compact
# ✓ Lean mode (Android): off
```
See [Troubleshooting](/troubleshooting) for more detailed solutions.
## Stream quality
Resolution and decoder are chosen automatically per viewer connection — tapflow streams in a **Standard**, **Smooth**, or **Remote** profile depending on how each viewer reaches the relay. See [Streaming Quality](/operate/streaming-quality) for the profiles and how to tune them.
## Host display and sleep
While an agent is connected, it holds a macOS power assertion so the host doesn't sleep mid-session. By default it also keeps the display awake (`caffeinate -di`): when the display sleeps, macOS parks the GPU and throttles the simulator render and encoding, which shows up as a sluggish stream. Running an agent effectively dedicates the Mac to tapflow, so this is the default.
Set `TAPFLOW_ALLOW_DISPLAY_SLEEP=1` to let the display sleep normally (system sleep is still blocked).
---
# https://www.tapflow.dev/operate/ci-distribution
# Build Distribution
Connect your CI pipeline so every build lands in App Center automatically — no manual uploads.
## How it works
```
CI pipeline
→ POST /api/v1/builds (tapflow relay)
→ Build appears in App Center
→ Team opens browser, picks a device, and tests
PO / PM: does it match the spec?
Designer: does it match the design?
Backend: is the API wired up correctly?
QA: any bugs to report?
```
The CI job uploads the build artifact. From there, anyone on the team can test directly in the browser — no IDE, no device setup needed.
::: info Two testing paths
This guide covers the **manual review path**: CI delivers the build; people do the testing.
For automated testing where an LLM agent controls the simulator, see [MCP in CI/CD](/automation/mcp-ci). That is a separate, experimental feature.
:::
## Any build tool
tapflow works on the built artifact, not the build tool. A native Xcode or Gradle build, React Native, Flutter, or a cloud build service all produce a build you upload the same way. Once you have an artifact (`.app.zip`, `.tar.gz`/`.tgz`, or `.apk`), the rest of this page is the same.
## Prerequisites
| Requirement | Notes |
|-------------|-------|
| tapflow relay | Running and reachable from your CI environment |
| Personal access token (PAT) | Create an API-type token in **Settings → Tokens** (it carries `builds:write`). Admins, Developers and QA see this page |
## How CI reaches the relay {#how-ci-reaches-the-relay}
Your CI job has to reach the relay's `POST /api/v1/builds`. The relay is meant to stay on the same internal network as the agents ([Deployment options](/operate/deployment)), so the path depends on where CI runs.
| Relay setup | How CI uploads |
|-----------|----------------|
| **LAN only (default)** | Cloud runners (GitHub-hosted and the like) cannot reach a LAN relay. Upload from a self-hosted runner on the internal network, using the relay's internal address (`http://192.168.x.x:4000`) |
| **VPS + rathole tunnel** | Open the relay for [external access](/operate/external-access) and CI can upload from anywhere via the public URL (`https://your-vps.com`) — the smoothest fit for cloud CI |
| **Tailscale tunnel** | Only tailnet members can connect, so the CI runner has to be on the tailnet |
::: tip The relay does not go on a cloud host
Deploying the relay to fly.io, Railway, or similar puts the agent→relay path over the internet and the stream breaks (unsupported). When you need public access, keep the relay on the internal network and expose it through a tunnel. The VPS is the tunnel host, not the relay host.
:::
## 1. Generate a token
In the dashboard, go to **Settings → Tokens → New token**. The Tokens page is shown to Admins, Developers and QA; the token belongs to whoever creates it.
- **Name**: something descriptive, e.g. `GitHub Actions`
- **Expiration**: 30 days by default. Pick 7, 30, 60 or 90 days, a custom number (1–365), or **No expiration**. For CI, 90 days or less is recommended.
- **Type**: **API**, which grants `view, builds:write`
Copy the token — it is shown only once. Store it as a CI secret (e.g. `TAPFLOW_PAT`).
## 2. Upload the build
Call `POST /api/v1/builds` after the build artifact is ready.
```sh
# iOS (.app.zip)
curl -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH"
# Android (.apk)
curl -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.apk" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH"
```
`status=In Progress` signals to the team that the build is ready for review.
Use `label` to attach context — branch name, ticket number, or a short description.
::: warning iOS builds
`.ipa` files are not supported. For a simulator build, upload `.app.zip` or `.tar.gz`/`.tgz`. Build `.app.zip` with `xcodebuild -sdk iphonesimulator` and zip the `.app` folder; a `.tar.gz`/`.tgz` is what a cloud simulator build produces.
:::
## 3. Post build metadata (optional)
Attach commit and branch info as a comment so reviewers know what changed.
```sh
BUILD_ID=$(curl -sf -X POST https://your-relay/api/v1/builds \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$GIT_BRANCH" | jq -r '.id')
curl -X POST https://your-relay/api/v1/comments \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "build_id=$BUILD_ID" \
-F "body=Branch: $GIT_BRANCH
Commit: $GIT_SHA
$GIT_COMMIT_MSG"
```
## GitHub Actions example
This example assumes a self-hosted macOS runner that can reach the relay's internal address. If you have opened the relay on a public URL, as with [VPS + rathole](#how-ci-reaches-the-relay), you can switch `runs-on` to a cloud runner such as `macos-latest`.
::: warning Self-hosted runners and pull requests
A self-hosted runner executes whatever code the workflow checks out, on a Mac inside your network. The `if:` below skips pull requests opened from forks, so only branches in your own repository build there. Keep it, and don't attach self-hosted runners to a public repository that accepts outside pull requests.
:::
```yaml
name: Upload to tapflow
on:
push:
branches: [main, 'release/**']
pull_request:
jobs:
upload:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Build app
run: |
xcodebuild -scheme MyApp -sdk iphonesimulator \
-configuration Debug \
CONFIGURATION_BUILD_DIR=build/Debug
cd build/Debug && zip -r MyApp.app.zip MyApp.app
- name: Upload to tapflow
env:
TAPFLOW_PAT: ${{ secrets.TAPFLOW_PAT }}
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
BRANCH: ${{ github.head_ref || github.ref_name }}
COMMIT: ${{ github.sha }}
COMMIT_MSG: ${{ github.event.head_commit.message || github.event.pull_request.title }}
run: |
BUILD_RESPONSE=$(curl -sf -X POST "$TAPFLOW_RELAY_URL/api/v1/builds" \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "file=@build/Debug/MyApp.app.zip" \
-F "status=In Progress" \
-F "label=$BRANCH")
BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r '.id')
COMMENT="Branch: $BRANCH"$'\n'"Commit: $COMMIT"$'\n'"$COMMIT_MSG"
curl -sf -X POST "$TAPFLOW_RELAY_URL/api/v1/comments" \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-F "build_id=$BUILD_ID" \
-F "body=$COMMENT"
```
## Build status reference
| Status | Meaning |
|--------|---------|
| `Backlog` | Uploaded but not yet ready for review |
| `In Progress` | Ready — team can start testing |
| `Done` | Stakeholders approved |
| `Rejected` | Issues found, needs fixes |
CI sets `In Progress` on upload. `Done` and `Rejected` are set manually after review.
---
# https://www.tapflow.dev/operate/configure
# Configuring tapflow
`tapflow init` sets up this machine's tapflow: it writes `tapflow.config.json`, the agent documentation files, and, when you choose DNS auto-issue, the credentials `.env`. If the defaults (port 4000, no tunnel, HTTP) are fine, skip this step and go straight to `tapflow start`.
## Run tapflow init
Run it from anywhere. tapflow keeps one install per machine, in `~/.tapflow` by default, and every command finds it the same way.
```sh
tapflow init
```
The command can create up to five things, all inside the install directory.
| Output | Contents |
|--------|----------|
| `tapflow.config.json` | Relay configuration. Holds the port, tunnel, HTTPS settings you choose, the data directory, and Lean mode for this machine's agent. |
| `AGENTS.md` | A tapflow section for coding agents, between `` markers. Anything you write outside them is kept when you run `init` again. |
| `CLAUDE.md` | One line, `@AGENTS.md`, for Claude Code. Written only when the install directory is tapflow's own — in a directory that is also something else, such as an app repository an older install lives in, a `CLAUDE.md` would stop Claude Code reading that repository's other `AGENTS.md` files. |
| `data/.env` | Holds DNS / ACME credentials. Created only when you pick DNS auto-issue. |
| `.gitignore` entry | Adds the data directory and `/.tapflow/artifacts/` when the install directory is inside a git repository, so runtime data and tokens are never committed (`.tapflow/flows/` stays tracked). |
Running `init` again keeps your configuration and refreshes only the tapflow section of `AGENTS.md`. Pass `--force` to write a fresh configuration.
The interactive prompts then appear in order. You pick a tunnel first; the streaming and certificate prompts only show when you run on the LAN with no tunnel, and the Lean mode prompt only where there is a simulator or emulator to make lean.
```text
1. Tunnel None · Tailscale · rathole
2. Streaming Only when tunnel is None — Standard (HTTP) · Smooth (HTTPS)
3. Certificate Only when Smooth is chosen — DNS auto-issue · Existing cert
4. Lean mode On a Mac, or where adb is installed — Off · On
```
## 1. Pick a tunnel
This decides how teammates reach the relay.
| Choice | Meaning |
|--------|---------|
| **None** | Reachable on the same LAN only. This is the default. |
| **Tailscale** | External access over an encrypted overlay network. No VPS required. |
| **rathole** | A fully public URL through a VPS you own. |
Each tunnel's setup steps and prerequisites are covered in [External access](/operate/external-access).
::: tip Non-interactive environments (CI)
To set the tunnel without prompts, pass a flag: `tapflow init --tunnel tailscale` or `tapflow init --tunnel rathole`. Use `--force` to overwrite an existing file; without it, `--tunnel` on a configured install stops rather than ignoring the flag. `--tunnel rathole` writes `tunnel.serverAddr` and `tunnel.publicUrl` as empty values, and every `tapflow` command fails config validation until both are filled in, so fill them in `tapflow.config.json` right away.
:::
## 2. Streaming performance (LAN only)
This step appears only when you pick **None** for the tunnel. It sets the quality teammates on the same LAN stream at.
| Choice | Meaning |
|--------|---------|
| **Standard** | Starts instantly over HTTP. Uses software decode and needs no domain. |
| **Smooth** | Turns on hardware decode (WebCodecs) over HTTPS. Smoother, but needs a domain. |
Browser hardware decoding runs only in a secure context (HTTPS), so to give teammates a smoother, more responsive stream, choose **Smooth** and set up HTTPS. How each choice maps to the actual resolution and decoder is explained in [Streaming Quality](/operate/streaming-quality).
::: info This step is skipped when you pick a tunnel
A tunnel handles HTTPS at its own layer, so this step only appears for a direct LAN connection. rathole terminates TLS with Caddy on the VPS; Tailscale terminates it with `tailscale serve` (free, optional). The relay needs no `tls` config either way. For the per-tunnel HTTPS setup, see [External access](/operate/external-access).
:::
## 3. Certificate method (when Smooth is chosen)
If you turn on HTTPS, choose how the certificate is provided.
| Choice | Meaning |
|--------|---------|
| **DNS auto-issue** | Auto-issues and renews a Let's Encrypt certificate with a Cloudflare or Vercel API token. Just enter your domain. |
| **Existing certificate (import)** | Point to an internal PKI or a certificate file you already hold. You manage renewal yourself. |
When you choose DNS auto-issue, you select a provider and enter a domain, and a `.env` for the token is scaffolded in the data directory. The full reference for issuance modes and config keys is in [Configuration — HTTPS](/reference/configuration#https-secure-context).
## 4. Lean mode
This decides whether the simulators and emulators your agent boots run lighter.
| Choice | Meaning |
|--------|---------|
| **Off** | Simulators run every background service. This is the default. |
| **On** | On iOS, a fixed list of background services is turned off while tapflow runs the simulator; measured on iOS 27, each simulator uses about a quarter less memory. On Android, four bundled Google apps are kept disabled; measured on API 34, each emulator uses a little under a fifth less. |
The answer is saved as `agent.lean` in `tapflow.config.json`, and you can change it there later. What turns off, what stays on and when it applies are listed in [Configuration — Lean mode](/reference/configuration#lean-mode-agent).
## The data directory's .env — holding secrets
`/.env` — `~/.tapflow/data/.env` on a default install — is the **default home for every relay secret**. Choosing DNS auto-issue makes `init` scaffold an empty template for the token, but this file holds more than DNS tokens — `JWT_SECRET`, the SMTP password, and any other secret go here too, one per line. Secrets stay out of `tapflow.config.json` and live in this gitignored file instead.
The file `init` creates holds only the token lines for the DNS provider you chose. Add a line for any other secret, as in the example below, and paste each value after the `=`.
```ini
# tapflow secrets — do not commit. Paste each value after the =.
TAPFLOW_CLOUDFLARE_TOKEN=
JWT_SECRET=
SMTP_PASS=
```
| Aspect | Detail |
|--------|--------|
| What goes in | Any relay secret — DNS provider tokens (`TAPFLOW_CLOUDFLARE_TOKEN` / `TAPFLOW_VERCEL_TOKEN`), `JWT_SECRET`, `SMTP_PASS`, and so on. |
| When it's read | The relay reads it first thing on start, before applying any other setting. |
| Permissions | Created with `0600` so only the owner can read it. |
| Precedence | **Shell env > `.env` > `tapflow.config.json`.** A shell variable set for the same key overrides the file value. |
`TAPFLOW_DATA_DIR` is the one exception: it decides where `.env` lives (`/.env`), so it can't be read from `.env`. Set the data directory in `tapflow.config.json` or the shell instead.
This way you don't re-export secrets every time you restart the relay. Put them in the file once, and the relay reads them on boot — handy for long-running setups under PM2 or launchd.
## What gets created
After `tapflow init` finishes, the install directory looks like this.
```text
~/.tapflow/
tapflow.config.json ← relay configuration
AGENTS.md ← tapflow section for coding agents
CLAUDE.md ← @AGENTS.md
data/ ← relay runtime state: db, uploads, secrets
.env ← only when DNS auto-issue is chosen
```
The relay fills `data/` in on first start.
## What lives where
Flow files are not part of the install. They belong to your app repository, next to the code they test.
```text
~/.tapflow/ ← this machine: one install, whatever you run
tapflow.config.json
data/ ← database, uploaded builds, secrets
your-app/ ← your repository: reviewed, committed, run in CI
.tapflow/
flows/ ← the flow YAML you commit
artifacts/ ← failure screenshots from `tapflow flow run` (gitignored)
```
The line between them is how each side comes back. The repository half is restored by `git clone`; the machine half from a backup, because a database, uploaded builds and a signing key cannot be committed. Flow files also have to be in the repository for CI to run them at all — a runner checks out your app, not your home directory.
Uploaded builds are the part that grows, and on a Mac they sit inside your home directory, so Time Machine backs them up with everything else. Set `TAPFLOW_HOME` to put the install somewhere else — `/var/lib/tapflow` on a server, as the [systemd example](/operate/relay-operations#systemd-linux-relay-server) does.
## Which install a command uses
Every command answers this the same way, in this order:
| | Install directory |
|---|---|
| `TAPFLOW_HOME` is set | that directory |
| the current directory is already an install — it holds a `tapflow.config.json`, or a `.tapflow/data` or `.tapflow-data` with data in it | the current directory |
| otherwise | `~/.tapflow` |
The second rule is what keeps an install created before tapflow had a home working exactly where it is. `tapflow start` and `tapflow relay start` print the directory, the configuration file and the data directory they resolved, so you can always see which one is in use.
Set `TAPFLOW_HOME` for a server or a second install: `TAPFLOW_HOME=/var/lib/tapflow tapflow init` creates that directory, and every later command that carries the same variable uses it. A `TAPFLOW_HOME` naming a directory that does not exist stops any command that runs the relay, as well as `agent start`, `status`, `logs` and `admin init`, rather than quietly starting an empty install somewhere else.
Data lives at `/data` on a new install. An install that already has `.tapflow/data` or `.tapflow-data` keeps reading it, and `init` writes whichever one it found into `local.dataDir`, so the layout cannot change under you later.
Every key in `tapflow.config.json` and its environment-variable overrides are detailed in [Configuration](/reference/configuration).
## Asking a coding agent about tapflow
`init` writes an `AGENTS.md` into the install directory with a tapflow section between `` and ``. It tells a coding agent to answer from [the documentation index](https://www.tapflow.dev/llms.txt) rather than from memory, how to read any page as markdown, which files hold this install's secrets, and to compare `tapflow --version` against the changelog. Open your agent in that directory (`cd ~/.tapflow`) and it can also read your configuration and run `tapflow doctor`, `tapflow status` and `tapflow logs`.
Write your own notes outside the markers — `init` replaces only what is between them.
Claude Code reads `AGENTS.md` directly. A session that cannot (an older version, a third-party provider such as Amazon Bedrock, or telemetry turned off) reads `CLAUDE.md` instead, which is why `init` writes one containing `@AGENTS.md`. If your install directory already has a `CLAUDE.md`, add that line to it yourself; `init` says so rather than editing a file you wrote.
## Deployment configuration
### JWT_SECRET
If you don't set `JWT_SECRET`, the relay generates a strong per-install secret on first boot and persists it to the data directory (`jwt-secret`, owner-only). No action is required for a single relay.
Set it explicitly only when you need a fixed key — for example, to share one secret across multiple relay instances. Generate a secure random value:
```sh
openssl rand -hex 32
```
Add it as a `JWT_SECRET=` line in [the data directory's `.env`](#secrets-env), like any other secret.
Once set, keep this value stable — changing it invalidates all active sessions immediately. Only rotate if the secret is compromised or you want to force everyone to log out.
### tapflow.config.json
The relay reads `tapflow.config.json` from this machine's install directory — `~/.tapflow` by default, or wherever `TAPFLOW_HOME` points. On a server, set `TAPFLOW_HOME` in the service environment so the unit, your shell and any `tapflow` command you run by hand all mean the same install. See [Configuration](/reference/configuration).
## Next step
Once configured, start the relay and agent.
```sh
tapflow start
```
For how to start in each deployment scenario (single Mac, separate server, tunnel), see [Deployment options](/operate/deployment).
---
# https://www.tapflow.dev/operate/deployment
# Deployment options
The relay is a lightweight Node.js server. It routes WebSocket traffic and serves the dashboard, so it needs little CPU or memory. The agent is what drives simulators and emulators, and it runs on a Mac. Most teams start with one Mac running both.
## Choose a setup {#choose-a-setup}
| Setup | Fits when | Start with |
|---|---|---|
| [Single Mac](#local-single-mac) | One Mac runs the simulators, and teammates are on the same network | `tapflow start` |
| [Separate relay](#team-separate-relay-server) | Several Macs run simulators, and one always-on Mac serves the dashboard | `tapflow relay start`, then `tapflow agent start` on each Mac |
| [Relay in Docker](#relay-in-docker) | The relay lives on a Linux server or NAS, and agents stay on Macs | `docker compose up -d` |
Two things are added on top of any of them: [External access](/operate/external-access) for teammates outside the office network, and [HTTPS](/operate/https) for a smoother stream.
::: tip Keep agents and the relay on the same wired LAN
The agent streams video frames to the relay continuously, so the two must share a LAN. Different floors or VLANs in one building are fine, because internal routing keeps latency low. Placing an agent across the internet raises RTT and drops frames. **Wired Ethernet is recommended**; Wi-Fi works but can stutter on a Mac (AWDL), so see [Stream lag or stuttering](/troubleshooting/streaming#stream-lag) if playback hitches.
:::
## Single Mac {#local-single-mac}
The relay and the agent run on the same Mac, started by one command:
```sh
tapflow start
```
The banner prints the address teammates open, `http://:4000`. The relay generates its own `JWT_SECRET`, so there is nothing else to set. [Quick Start](/get-started/quick-start) walks through this setup from install to the first tap.
## Separate relay {#team-separate-relay-server}
The relay runs on one always-on Mac, and an agent runs on each Mac with simulators or emulators.
**On the relay Mac:**
```sh
tapflow relay start
```
**On each agent Mac:**
```sh
tapflow agent start --relay ws://192.168.x.x:4000 --token tflw_pat_xxxxxxxx
```
An agent on a different machine than the relay needs an `agent`-scope token. [Remote relay authentication](/operate/agents#remote-relay-authentication) shows how to create one. The scheme is `ws://`, or `wss://` when the relay serves [HTTPS](/operate/https); the relay prints the exact command when it starts.
## Relay in Docker {#relay-in-docker}
The official image runs the relay only. Agents still run on Macs on your LAN and connect to the Docker host with an `agent`-scope token, the same way as with a separate relay. The Compose file, the required data volume and the first account are in [Deploy with Docker](/operate/docker).
## Teammates on the same network {#internal-access-same-network}
Teammates open `http://:4000` in a browser. The port is `local.port` in `tapflow.config.json` (default `4000`). Nothing needs to be installed on their side. To pin a fixed signing key instead of the generated one, see [JWT_SECRET](/operate/configure#jwt-secret).
## Moved sections {#moved-sections}
Sections that used to be on this page now live on these pages.
- [Deploy with Docker](/operate/docker)
- [Deploy with Docker](/operate/docker#docker-compose-lan-server)
- [Configuring tapflow](/operate/configure)
- [Deployment configuration](/operate/configure#deployment-configuration)
- [JWT_SECRET](/operate/configure#jwt-secret)
- [tapflow.config.json](/operate/configure#tapflow-config-json)
- [External access](/operate/external-access)
- [External access](/operate/external-access#external-access)
- [Tailscale (recommended)](/operate/external-access#tailscale-recommended)
- [VPS + rathole](/operate/external-access#vps-rathole)
- [1. Set up Caddy for HTTPS on the VPS](/operate/external-access#_1-set-up-caddy-for-https-on-the-vps)
- [2. Configure tapflow on the relay Mac](/operate/external-access#_2-configure-tapflow-on-the-relay-mac)
- [HTTPS](/operate/https)
- [Enable HTTPS for the smoother stream (optional)](/operate/https#enable-https-for-the-smoother-stream-optional)
- [Backups & uptime](/operate/relay-operations)
- [Backup](/operate/relay-operations#backup)
- [Recommended: Litestream for SQLite](/operate/relay-operations#recommended-litestream-for-sqlite)
- [PM2 (keeping the relay Mac always on)](/operate/relay-operations#pm2-keeping-the-relay-mac-always-on)
- [systemd (Linux relay server)](/operate/relay-operations#systemd-linux-relay-server)
---
# https://www.tapflow.dev/operate/docker
# Deploy with Docker {#docker-compose-lan-server}
You can run the relay via Docker on an always-on LAN box. This provides a clean deployment using the official image, freeing you from installing Node.js globally.
```sh
docker pull tapflow/tapflow:latest
```
::: tip Hitting Docker Hub's pull limit? The same image is on GHCR
Docker Hub limits anonymous pulls to 100 per six hours per IP address, and a multi-architecture image spends one of those per architecture — so roughly 50 in practice, shared by everyone behind the same address. A CI runner or an office network can reach that without anyone doing anything unusual.
The identical image is published to the GitHub Container Registry, which has no such limit for public images:
```sh
docker pull ghcr.io/jo-duchan/tapflow:latest
```
Both registries receive the same digests from the same build, so `latest` and a version tag mean the same thing on either. Swap the `image:` line in the Compose file below if you prefer it.
:::
Create a `docker-compose.yml`:
```yaml
services:
relay:
image: tapflow/tapflow:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/.tapflow/data
restart: unless-stopped
```
Start the container:
```sh
docker compose up -d
```
::: warning Set `publicUrl`, or invite links point at the recipient's own machine
The relay never infers its address from the `Host` header — deliberately, because a forged one would
send a phishing link out as a normal invitation. With nothing configured it falls back to
`http://localhost:4000`, so an invitation mailed to a teammate opens *their* machine and fails.
Set `TAPFLOW_RELAY_URL` to the address people will actually type, in the same `environment:` block
as the variables below:
```yaml
environment:
- TAPFLOW_RELAY_URL=http://:4000
```
An environment variable rather than the config file, because the relay reads `tapflow.config.json`
from its install directory, which `TAPFLOW_HOME` in the image pins to `/app`, while the Compose
volume mounts `/app/.tapflow/data` — so a file placed in the volume is never opened. The same value also goes into the
CORS and CSRF allowlist, which a proxied deployment needs.
:::
::: warning Create the first account before you open the browser
The browser onboarding at `/setup` answers only requests from loopback, and a container reaches the
relay through the bridge gateway — so the page shows no form. It points to `tapflow admin init`
on the host, which the relay-only image does not contain. Set `TAPFLOW_ADMIN_EMAIL` and
`TAPFLOW_ADMIN_PASSWORD` and the relay creates the account as it starts. Both must be set — with only
one the relay refuses to start — and the password must be at least 8 characters. On an install that
already has an owner it does nothing. The
details, including which `.env` file is read, are in
[Configuration](/reference/configuration#create-the-first-admin-account-in-a-docker-container-tapflow-admin-email).
:::
::: danger A volume is mandatory
Notice the `./data:/app/.tapflow/data` volume above. It is strictly required. The relay writes a per-install secret to `/jwt-secret` and reuses it; without the volume that file lives in the container's writable layer. `docker restart` keeps it, because the layer survives — but anything that **recreates** the container loses it, including an image update, `docker compose down && docker compose up -d`, and `docker rm`. A new secret instantly logs out every user and breaks all agent connections.
:::
**Topology:** The container runs the relay *only*. Agents (which drive real simulators) must still run on Macs on your LAN, connecting outbound to this Docker server using an `agent`-scope token (`tapflow agent start --relay ws://:4000 --token ...`).
**A tunnel or proxy in the same network namespace:** set `TAPFLOW_TUNNEL_PORT` (or `local.tunnelPort`) and point it at the port it names. The relay does not ask connections that reach it over loopback to sign in, and a proxy sharing the container's namespace reaches it that way. Nothing here opens the tunnel port from a `tunnel` config: that is what `tapflow start` and `tapflow relay start` do, and this image runs neither. Naming the port is what opens it. A proxy on another host reaches the relay over the bridge and is remote already, so it needs nothing.
::: danger Do not deploy the relay directly to a cloud service
Deploying the Docker container to fly.io or similar services puts the agent→relay path over the internet. RTT then exceeds the 30fps threshold (33ms/frame), causing persistent frame drops with no way to recover. tapflow does not support this configuration.
:::
---
# https://www.tapflow.dev/operate/environment-setup
# Environment Setup
The Mac that will run an agent needs an environment that can launch iOS simulators or Android emulators. Use `tapflow doctor` to diagnose the current state, and `tapflow setup` to install and configure whatever is missing.
## tapflow doctor
`tapflow doctor` checks whether the environment is ready.
```sh
tapflow doctor
```
Pass a platform to check only that one.
```sh
tapflow doctor ios
tapflow doctor android
```
It checks the following:
| Area | Checks |
|------|--------|
| Common | Node version, whether port 4000 is free |
| iOS | Xcode, `xcrun simctl`, an available simulator, network filter (installed state and version), network hook, Lean mode |
| Android | Android SDK, adb, build-tools (aapt), AVD, Lean mode |
Each item shows as **✓ ready**, **⚠ attention**, or **✗ needs install**. It does not check whether a device is running — booting happens automatically when someone opens a session in the dashboard, so a single bootable device is enough to pass.
The port check always looks at 4000, whatever port your config sets. If something already holds port 4000 (such as a relay on the default port), that check fails and doctor exits with code `1`, so run it with the relay stopped or ignore that line.
To parse the result from automation or CI, use `--json` for machine-readable output.
```sh
tapflow doctor --json
```
## tapflow setup
`tapflow setup` installs and configures whatever `doctor` reported as missing. Run it without an argument to detect the environment and set up every supported platform.
```sh
tapflow setup
```
You can also target a single platform.
```sh
tapflow setup ios
tapflow setup android
```
setup is designed to finish in one run. For steps that require installation, it asks for consent in an interactive terminal and then runs the command for you; in non-interactive environments (CI and the like) it prints the command to run instead of installing, and the run ends with exit code `1` because the work is still outstanding.
### iOS
- **Homebrew** is installed if missing.
- **Xcode** cannot be installed by setup itself, so setup opens the App Store and guides you. Press Enter after the install completes to continue. Xcode is also available from [Apple's developer site](https://developer.apple.com/xcode/).
- The **Xcode activation** step accepts the license and runs the first-launch setup (`xcode-select`, `xcodebuild -runFirstLaunch`) after consent. These steps require administrator (sudo) access.
- It downloads a **simulator runtime** when no device is available yet.
- **Audio permission**: requests the permission simulator audio capture needs, ahead of time. See [Audio](/testing/audio).
- **Network filter**: installs the filter that network control uses. See [Network Control](/testing/network-control).
### Android
- **Homebrew** is installed if missing; the JDK and SDK command-line tools steps below use it.
- **JDK**: installs a JDK with `brew install --cask temurin` if the one required to run the SDK tools is missing.
- **Android SDK**: builds a self-contained SDK at `~/Library/Android/sdk` with the command-line tools, platform-tools, emulator, build-tools (aapt), and a system image. The `aapt` from build-tools reads an APK's app name, version, and package name at upload time. The Android Studio GUI is not required.
- **AVDs**: creates four devices across form factors (compact phone, standard phone, large phone, tablet) so you can test across resolutions.
When setup adds `ANDROID_HOME` and PATH to your shell config, the current terminal doesn't pick them up. Open a new terminal (or run `exec $SHELL`), then verify with `tapflow doctor`.
## Booting is automatic
setup only goes as far as preparing a bootable device or AVD. The actual boot is handled by the relay when a teammate opens a QA session in the dashboard, so there's no need to launch a device yourself right after setup.
---
# https://www.tapflow.dev/operate/external-access
# External access
Keep the relay and agents on the same internal network at all times. External access works by opening an outbound tunnel from the relay Mac to a public endpoint — browsers connect to the public URL, which forwards traffic back to the relay.
The relay requires authentication on every connection that does not come from localhost. Browsers authenticate by signing in; agents authenticate with an `agent`-scope token — the agent side is covered in [Remote relay authentication](/operate/agents#remote-relay-authentication).
tapflow supports two tunnel providers:
::: tip tapflow init writes the tunnel config for you
The `tunnel` blocks shown below can be generated interactively — run `tapflow init` and pick a provider. See [Configuring tapflow](/operate/configure). The sections here cover the resulting config and the provider-side setup.
:::
| | Tailscale | VPS + rathole |
|---|-----------|---------------|
| **Setup** | Install app + sign in | VPS with SSH access required |
| **Cost** | Free Personal plan for non-commercial use; paid plans for business | VPS running cost |
| **Who can connect** | Tailscale tailnet members only | Anyone with the URL |
| **Best for** | Internal teams | External collaborators, public demos |
## Tailscale (recommended)
[Tailscale](https://tailscale.com) is a zero-config VPN built on WireGuard. It creates an encrypted overlay network (a "tailnet") across your devices — no VPS, no port forwarding, no static IP required.
```text
browser (tailnet) ──[WireGuard E2E]──► relay Mac (tailnet)
↑
agent Macs (same internal network)
```
Traffic never leaves your infrastructure in plaintext. Even when Tailscale's DERP relay is used as a fallback, only encrypted WireGuard packets pass through — Tailscale servers cannot decrypt them.
**Prerequisites**: Install Tailscale on the relay Mac and on every browser machine that needs access.
- [Download Tailscale →](https://tailscale.com/download) — macOS, Windows, Linux, iOS, Android
- The free Personal plan is for non-commercial use; a team using it for work needs a business plan · [Pricing →](https://tailscale.com/pricing)
1. Install and connect Tailscale on the relay Mac:
```sh
brew install tailscale # macOS
sudo tailscale up
```
2. Add the `tunnel` section to `tapflow.config.json`:
```json
{
"tunnel": {
"provider": "tailscale"
}
}
```
3. Start. The command depends on your deployment scenario.
If you run the relay and agent on the same Mac:
```sh
tapflow start
```
If the relay runs on a dedicated Mac:
```sh
tapflow relay start
```
tapflow reads the Tailscale MagicDNS hostname (or tailnet IP) automatically and prints the public URL in the banner. Teammates with Tailscale installed connect to that URL in their browser.
::: tip Custom URL
Set `"publicUrl": "http://your-hostname.tailnet.ts.net:4000"` in the tunnel config to override the auto-detected URL.
:::
::: info Agents stay on the internal network
Tailscale only provides the browser→relay path. Agents (simulator Macs) still connect to the relay's internal IP over your LAN — no change needed there.
:::
::: warning Run tailscaled with a TUN device
The relay does not ask connections that reach the relay port over loopback to sign in. In userspace-networking mode (`tailscaled --tun=userspace-networking`, common in containers), Tailscale hands every tailnet connection to the relay from inside the relay's machine, so those visitors skip sign-in. Use the default TUN mode. `tapflow start` and `tapflow relay start` warn when they detect userspace networking.
:::
### Enable HTTPS for the smoother stream (optional)
The default Tailscale URL is plain HTTP, and tailnet addresses count as external, so teammates get a stream trimmed to 1000 px and decoded in software. Serving the relay over Tailscale's free HTTPS moves them to the Smooth profile; the steps are in [HTTPS through Tailscale](/operate/https#tailscale).
## VPS + rathole
Use this when you need a fully public URL — for external collaborators, anonymous demos, or when Tailscale isn't an option. Traffic is routed through a VPS you own.
```text
browser → VPS (public URL) → tunnel → relay Mac (office)
↑
agent Macs (same internal network)
```
tapflow uses [rathole](https://github.com/rapiz1/rathole) — a lightweight reverse tunnel — to open an outbound connection from the relay Mac to your VPS. tapflow manages rathole automatically: it downloads, installs, and starts rathole on the VPS on first run. No manual setup on the VPS is needed.
**Prerequisites**:
- A VPS with SSH access. Any provider works (1 vCPU + 512 MB RAM is enough). Popular choices: [Hetzner](https://www.hetzner.com), [DigitalOcean](https://www.digitalocean.com), [Vultr](https://www.vultr.com).
- A domain or [sslip.io](https://sslip.io) for HTTPS (handled by [Caddy](https://caddyserver.com)).
- `TAPFLOW_TUNNEL_TOKEN` — a secret string you choose. This is shared between the relay Mac and the rathole server to authenticate the tunnel. Pick any random value; keep it private.
The relay Mac opens an outbound tunnel to the VPS over SSH, so no port forwarding or static IP is required — CGNAT is not a problem.
### 1. Set up Caddy for HTTPS on the VPS
Caddy handles TLS automatically — no certbot needed.
```sh
sudo apt install -y caddy
```
```caddyfile
# /etc/caddy/Caddyfile
your-vps.com {
reverse_proxy localhost:4000
}
```
```sh
sudo systemctl reload caddy
```
::: tip No domain? Use sslip.io
If you don't own a domain, `.sslip.io` works as a free HTTPS endpoint — for example `https://1.2.3.4.sslip.io`. Caddy issues a Let's Encrypt certificate automatically.
:::
### 2. Configure tapflow on the relay Mac
Add the `tunnel` section to `tapflow.config.json`:
```json
{
"tunnel": {
"provider": "rathole",
"serverAddr": "your-vps.com:2333",
"publicUrl": "https://your-vps.com",
"ssh": {
"host": "your-vps.com",
"user": "ubuntu",
"keyPath": "~/.ssh/id_ed25519"
}
}
}
```
Put the tunnel token in the `.env` in the data directory (`~/.tapflow/data/.env` on a default install):
```ini
TAPFLOW_TUNNEL_TOKEN=your-secret-token
```
Then start. If you run the relay and agent on the same Mac:
```sh
tapflow start
```
If the relay runs on a dedicated Mac:
```sh
tapflow relay start
```
tapflow connects to the VPS over SSH, downloads and installs rathole automatically on first run, then starts both the VPS-side server and the local tunnel client. The public URL is printed in the banner when the tunnel is ready.
Browsers connect to `https://your-vps.com`; agents still connect to the relay's internal IP (`ws://192.168.x.x:4000`).
On the relay Mac, the tunnel hands visitors to the relay's **tunnel port** (`127.0.0.1:4001`), not to `4000`. The relay counts every connection on that port as remote, even though it comes from the Mac itself, so visitors sign in and tools present a token. If `4001` is taken, set `TAPFLOW_TUNNEL_PORT`; tapflow points the tunnel at whichever port the banner shows.
::: tip VPS firewall
Open ports `2333/tcp` (rathole) and `443/tcp` (Caddy) on the VPS. Keep port `4000` closed. Caddy reaches it from inside the VPS, and a direct connection would skip TLS.
:::
::: danger Do not deploy the relay directly to a cloud service
Deploying the relay to fly.io, Railway, or similar services puts the agent→relay path over the internet. RTT then exceeds the 30fps threshold (33ms/frame), causing persistent frame drops with no way to recover. tapflow does not support this configuration.
:::
---
# https://www.tapflow.dev/operate/https
# HTTPS
Browsers decode video in hardware only on HTTPS pages. Over plain HTTP, teammates on the LAN get the **Standard** stream: 1280 px, decoded in software. Serving the relay over HTTPS moves them to **Smooth**, at native resolution and decoded in hardware. [Streaming Quality](/operate/streaming-quality) shows how each kind of connection maps to a profile.
HTTPS is optional. Standard needs no setup, and you can add HTTPS later without changing anything else.
## Pick a method {#pick-a-method}
| How teammates reach the relay | Method | What you need |
|---|---|---|
| Directly, on the same LAN | [Certificate from your DNS account](#dns-auto-issue) | A domain on Cloudflare or Vercel and an API token for it |
| Directly, on the same LAN | [A certificate you already have](#import-cert) | Certificate and key files, such as an internal PKI or wildcard certificate |
| Through Tailscale | [`tailscale serve`](#tailscale) | Tailscale's free HTTPS certificates |
| Through a VPS with rathole | Caddy on the VPS | The steps in [External access](/operate/external-access#_1-set-up-caddy-for-https-on-the-vps) |
With a tunnel, TLS ends in front of the relay, so the relay itself stays on HTTP and needs no `tls` settings.
## Certificate from your DNS account {#dns-auto-issue}
The relay gets a Let's Encrypt certificate over DNS-01 and renews it on its own. It also points the domain's A record at this Mac's LAN address, so teammates only need the domain.
1. Run `tapflow init` on the relay Mac and choose:
- **Tunnel provider**: None
- **Streaming performance**: Smooth
- **Certificate method**: Cloudflare DNS or Vercel DNS
- **Domain for tapflow**: for example `tap.yourcompany.com`
2. `init` creates `.env` in the data directory (`~/.tapflow/data/.env` on a default install) with a line for the token. Paste the API token after `TAPFLOW_CLOUDFLARE_TOKEN=` or `TAPFLOW_VERCEL_TOKEN=`. For a Vercel team domain, also add `TAPFLOW_VERCEL_TEAM_ID`.
3. Start the relay with `tapflow start`, or `tapflow relay start` on a relay-only Mac. The banner shows `Relay started on https://:4000`.
4. Share that address with teammates. Opening the relay by IP address or `localhost` shows a certificate name warning, because the certificate is for the domain.
Remote agents now connect with `wss://` instead of `ws://`. The agent connection command the relay prints already uses it.
## A certificate you already have {#import-cert}
Use this for an internal PKI or a wildcard certificate you already hold. You renew it yourself.
1. Run `tapflow init`, choose **None**, **Smooth** and **Existing certificate**, and enter the paths to the fullchain certificate and the private key. Or add the `tls` block to `tapflow.config.json` yourself:
```json
{
"tls": {
"mode": "import-cert",
"certPath": "/path/to/fullchain.pem",
"keyPath": "/path/to/privkey.pem"
}
}
```
2. Make sure the name on the certificate resolves to the relay's LAN address, in your DNS or on each teammate's machine.
3. Start the relay. The banner shows the name tapflow read from the certificate. If it shows `localhost` with a warning, the certificate has no name tapflow can use; [Configuration](/reference/configuration#https-secure-context) lists which names it accepts.
## Through Tailscale {#tailscale}
The default Tailscale URL is plain HTTP, and tailnet addresses count as external, so teammates get a stream trimmed to 1000 px and decoded in software. Terminating over Tailscale's free HTTPS brings them in through the tunnel port, which moves them to the Smooth profile. Tailscale issues and renews the `*.ts.net` certificate automatically, so no domain or DNS token is needed.
1. In the Tailscale admin console under **DNS**, enable **MagicDNS** and **HTTPS Certificates**. You'll acknowledge that machine names appear in the public Certificate Transparency log.
2. On the relay Mac, terminate HTTPS in front of the relay's **tunnel port**. It is `TAPFLOW_TUNNEL_PORT` when you set it, and `4001` otherwise; only when the relay itself runs on 4001 does the default step aside to 4002. The command below uses the default, so replace `4001` with the port the start banner prints if yours differs:
```sh
tailscale serve --bg 4001
```
::: warning Serve the tunnel port, not 4000
`tailscale serve` connects to the relay from the relay Mac itself. On port `4000` the relay treats those connections as local and does not ask them to sign in. On the tunnel port every connection counts as remote. If an earlier setup serves `4000`, run `tailscale serve reset` and then the command above. `tapflow start` warns while the old setting is in place.
:::
3. Point `publicUrl` at the HTTPS address in `tapflow.config.json` so the banner and the links tapflow shares match:
```json
{
"tunnel": {
"provider": "tailscale",
"publicUrl": "https://your-hostname.tailnet.ts.net"
}
}
```
Setting up Tailscale itself is in [External access](/operate/external-access#tailscale-recommended).
## Check that it worked {#check}
Open the dashboard at the HTTPS address and start a QA Session. The label beside the frame rate under the device reads **Smooth**. When it reads **Standard**, the page is still on HTTP.
## When teammates cannot connect {#troubleshooting}
- **Certificate name warning**: open the domain, not an IP address or `localhost`.
- **The domain does not load on the LAN**: some routers block a public domain that points to a private address (DNS rebinding protection). Add an exception on the router, or map the domain to the LAN address in local DNS.
- **Nothing on the LAN loads**: networks with Wi-Fi client isolation block traffic between devices. Use a normal home or office network.
- **Browser warns about the certificate after testing**: a staging certificate (`TAPFLOW_ACME_STAGING=1`) is not trusted. After switching to production, the browser may keep showing the old error; check in a private window.
Every `tls` key is in [Configuration](/reference/configuration#https-secure-context).
---
# https://www.tapflow.dev/operate/network-extension
# iOS network extension
Install the network extension that iOS network control needs on the agent Mac, and recover when it stops working.
## Install the network extension {#network-not-set-up}
Network control on an iOS simulator needs the tapflow network extension installed on the agent Mac. **It comes with tapflow, so there is nothing to download.** One command installs the copy already in the package.
### 1. Install it
On a Mac you are setting up for the first time, the iOS setup covers the extension too.
```sh
tapflow setup ios
```
On a Mac that already ran setup, setup does not run again, so a machine configured before this feature existed needs its own command.
```sh
tapflow migrate net-filter
```
### 2. Approve it
Requesting the install brings up a macOS approval prompt. Choose **Open System Settings**. The highlighted **OK** only closes the prompt and approves nothing.
Go to **System Settings → General → Login Items & Extensions → Network Extensions** and switch the tapflow entry on. (An administrator password is required.)
Approval happens at the Mac. macOS offers no path a browser could click instead.
**The command can open the approval screen for you.** When this Mac has no approved tapflow extension, it asks before the install starts whether to open the screen when macOS asks. Say yes and the screen opens as soon as macOS starts waiting; switch TapflowNetFilter on there and the install finishes. macOS does not show its prompt again for an extension that is already waiting, so on a rerun this screen is the quickest way there.
It asks only when both its input and output are a terminal, so a pipe or a redirect stops it from asking even in one.
**Missing the first two minutes is not the end of it.** The install waits up to two minutes for approval. If the entry is not switched on by then, and the command can ask and you did not decline its offer, it waits up to two more minutes and switches the filter on once the entry is on. If it did not ask up front, because macOS was not expected to ask (replacing an approved extension, for instance), it asks at this point whether to open the screen.
Switching the filter on can briefly drop connections this Mac already has open, SSH sessions included — if you are connected over SSH, run it at the Mac. If macOS asks whether to allow tapflow to filter network content when it switches on, allow it.
If no screen appears, go there by the path above. The command cannot tell whether the window opened, so it shows the path alongside.
If you declined, or ran it where it cannot ask, the command ends still waiting for approval unless you approve it yourself within the first two minutes. A declined offer is not repeated. Not switching it on within the two extra minutes ends the same way. Approve it and run the same command once more. That run switches the filter on.
If a device came into use during the two extra minutes (a simulator booted, say), it stops without switching the filter on. It names what it found; stop that and run the command again. An approval within the first two minutes lets the extension switch the filter on by itself, so the only device check on that path is the one when the install starts.
### 3. When a restart is needed
Replacing an already-installed extension finishes only after the Mac restarts. **Until then the previous version keeps running** — the file on disk is the new one while macOS is still running the old one, so the dashboard goes on saying the Mac is not set up.
### Checking what the Mac has
```sh
tapflow doctor ios
```
It reports four things separately: whether it is **installed**, whether it is **approved**, whether it is **switched on**, and whether the versions on this Mac are the **ones this tapflow carries**. The last two are separate because the ones before them can all be right while the control still does not work — a replacement waiting for a restart is one such state, and a filter switched off is another. Switched off has no version of its own: the extension stays listed as activated, so every version reads correctly while nothing is being filtered.
**Two things carry a version**: the app in `/Applications` and the system extension inside it. They move independently, because a release that changes only the app has no reason to make macOS replace a running filter. The check names whichever is behind, and they need different things.
| What it says is behind | What to do |
|---|---|
| The app in `/Applications` | `tapflow migrate net-filter`. It copies the app; macOS skips the activation, so nothing is interrupted. The agent calls that binary, which is why a stale one matters |
| The extension, with a restart mentioned | Restart the Mac. The replacement is installed and finishes then |
| The extension, with no restart mentioned | `tapflow migrate net-filter` |
| This Mac is set up for a newer tapflow | Upgrade this checkout instead. Migrate refuses that direction, because replacing a newer filter breaks the agent depending on it |
**If the app is gone but the extension is still running, tapflow refuses to reinstall.** The extension's version says which filter is running, not which app it came from, so nothing can tell whether that Mac was set up by a newer tapflow than yours — and installing over it would replace a working filter someone else may depend on. Reinstall from the tapflow whose version matches, or clear the extension and start again. `tapflow doctor ios` and the command that refused both print the steps:
1. Switch the filter off. The app is gone, so this uses the binary inside the package, and the command prints its exact path.
2. Click the ⋯ button beside TapflowNetFilter and choose **Delete Extension**, in System Settings → General → Login Items & Extensions → Network Extensions. This works with the app already gone from `/Applications`.
3. Restart the Mac. The removal finishes then.
`systemextensionsctl uninstall` is not an option: macOS refuses it on any Mac with System Integrity Protection (SIP) on.
### If it still does not work
Installing ends with a distinct code per kind of failure.
| Code | Meaning |
|---|---|
| 1 | Activation failed |
| 2 | Could not read the configuration |
| 3 | Could not save the configuration |
| 4 | Not approved within 120 seconds. With input and output both on a terminal, the command keeps waiting, first asking whether to open the approval screen if it has not asked yet. If it is still not switched on after that, it could not ask, or the offer was declined, the command ends waiting for approval: approve it in System Settings and run it again |
| 5 | The Mac has to restart for this to finish |
| 6 | The system extension manager gave no answer within 45 seconds |
| 7 | The running filter did not answer |
| 8 | An argument this build does not understand. Can happen when the installed filter app is older than the agent |
What the extension can and cannot see is in [Network Control](/testing/network-control#what-you-are-trusting).
## Troubleshooting
### The Mac lost its network while the filter was being replaced {#network-lost-on-replace}
The filter decides on **every new connection the Mac makes**, not only the simulator's. When it stops
while it is still switched on, macOS does not let traffic through unchecked. It blocks all of it,
which is the safe choice for a filter and a sudden one for you. Replacing the extension stops the
filter for a moment, so the replacement is where this happens.
The failures are immediate rather than slow: **`No route to host`**, not a long wait. Connections
already open keep working, which is why some things carry on while a browser stops.
`tapflow migrate net-filter` switches the filter off twice on the way through: once before it copies
the new app in, and once before it activates. The second is a gate — rather than activate over a filter
it could not switch off, the command stops and tells you. The first is best effort, so on a Mac where
it does not take you are back to the narrow window this section describes rather than clear of it.
When the command does stop, it says whether the filter was left switched off. Your network works in
that state and only iOS network control is missing.
**Getting the network back does not need a restart.**
```sh
/Applications/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter --off
```
That takes the filter out of the path and traffic returns. iOS network control stays unavailable
until the filter is on again, which is what running the migration again does.
```sh
tapflow migrate net-filter
```
### A device that was offline came back on the network by itself {#network-stopped}
A notice saying the device went back on the network while you were checking means **the offline behaviour you have checked so far needs checking again.** Requests may have been succeeding between the moment traffic started passing and the moment the notice appeared.
What stopped is the filter on the agent Mac that was blocking the traffic — either the extension was switched off in System Settings, or the filter process died and macOS is bringing it back. Bringing it back takes about six seconds, and nothing is blocked for any of them.
**Check**
```sh
systemextensionsctl list
```
If `dev.tapflow.netfilter.ext` is not `[activated enabled]`, go back to [installing and approving it](#network-not-set-up).
If it is enabled and this keeps happening, the filter process is dying repeatedly.
```sh
log show --last 10m --predicate 'subsystem == "dev.tapflow.netfilter"' --info --debug --style compact
```
**What to do**
Take the device offline again and redo the check from the start. The button draws normally once the filter is back. If the same notice keeps appearing, offline checks on that Mac cannot be trusted until the cause is found, so use another agent Mac in the meantime.
Logs are at `/tmp/tapflow-netfilter-host.log`.
For the feature itself, see [Network Control](/testing/network-control).
---
# https://www.tapflow.dev/operate/relay-operations
# Backups & uptime
Back up the relay's data, and have the relay come back on its own after a reboot or a crash.
## Backup
The relay keeps its durable state under the resolved data directory — `~/.tapflow/data/` on a default install, or `/.tapflow/data/` for one that lives in its own folder from an earlier version. `tapflow start` and `tapflow relay start` print the directory they resolved; `TAPFLOW_DATA_DIR` overrides `local.dataDir`. Back that directory up before OS upgrades, relay migration, or any long-running team pilot.
If you are upgrading from a version that stored state in `.tapflow-data/`, nothing breaks: a pinned `local.dataDir` is honored and a config-less install keeps reading the existing `.tapflow-data/`. Stop the relay and run `tapflow migrate data-dir` once to adopt the unified layout: it atomically renames `.tapflow-data/` → `.tapflow/data/` (no copy, no data loss), repoints `local.dataDir` when it pinned the old default, and updates `.gitignore`.
The paths below are inside that data directory — the one on the `Data →` line `tapflow start` prints.
Important paths:
| Path | Why it matters |
|------|----------------|
| `tapflow.db` | SQLite database for accounts, apps, builds, sessions, comments, tokens, and settings. |
| `tapflow.db-wal` / `tapflow.db-shm` | SQLite WAL sidecar files. Include them in filesystem snapshots, or use Litestream so changes are captured safely. |
| `uploads/` | Uploaded build artifacts served by the relay. |
| `recordings/` | Session recordings uploaded through the relay. |
| `run-screenshots/` | Screenshots in flow run records: the screen a flow failed on, and a passed flow's last screen. |
| `run-videos/` | Flow run videos. They are large and deleted by the retention policy, so backing them up is optional. Restore without them and the relay marks each missing video as no longer kept on its next start. |
| `.env` and `jwt-secret` | Relay secrets. Keep them private and restore them with the data directory so existing sessions and integrations keep working. |
### Recommended: Litestream for SQLite
[Litestream](https://litestream.io/) is an external process; tapflow does not bundle, install, or supervise it. Litestream streams SQLite WAL changes to object storage such as AWS S3, Cloudflare R2, Backblaze B2, or any S3-compatible endpoint. It does not require tapflow schema changes or a separate database server.
Install Litestream on the relay host:
```sh
brew install litestream
```
On Linux, install the Litestream release binary for your architecture from the official releases page.
Create `litestream.yml` next to your tapflow config, with an absolute database path — Litestream resolves a relative one against its own working directory, which is not necessarily the install:
```yaml
dbs:
- path: /Users/you/.tapflow/data/tapflow.db
replicas:
- type: s3
bucket: YOUR_BUCKET
path: tapflow/relay/tapflow.db
endpoint: YOUR_S3_ENDPOINT
```
Set the credentials required by your storage provider, then run Litestream alongside the relay:
```sh
litestream replicate -config litestream.yml
```
If you use PM2, keep the relay and Litestream as separate processes so each can restart independently:
```sh
pm2 start tapflow --name relay -- relay start
pm2 start litestream --name relay-backup -- replicate -config litestream.yml
pm2 save
```
Restore the database before starting tapflow on a new host:
```sh
DATA_DIR=/Users/you/.tapflow/data # ~/.tapflow/data, $TAPFLOW_HOME/data, or your TAPFLOW_DATA_DIR
litestream restore -config litestream.yml -if-replica-exists "$DATA_DIR/tapflow.db"
```
Work the directory out from those rules rather than by starting tapflow to see what it prints: the first start creates an empty `tapflow.db`, and `litestream restore` will not overwrite a database that already exists.
Then restore `uploads/`, `recordings/`, `run-screenshots/`, `.env`, and `jwt-secret` inside that same data directory from your file backup. Litestream protects the SQLite database only; build files, recordings, and secrets still need a normal filesystem or object-storage backup.
## PM2 (keeping the relay Mac always on)
Handles automatic restart on crash, restart on server reboot, and log management — run this on the relay Mac.
```sh
npm install -g pm2 tapflow
```
With `JWT_SECRET` in the data directory's `.env` (`~/.tapflow/data/.env` on a default install), or left unset to auto-generate, start:
```sh
pm2 start tapflow --name relay -- relay start
pm2 save
pm2 startup
```
To update tapflow:
```sh
npm update -g tapflow
pm2 restart relay
```
::: tip Next step
Once the relay is running, open `http://localhost:4000` in a browser — the dashboard redirects to the setup page automatically. For headless servers, use `tapflow admin init` instead. It asks for an email and a password, so run it from a terminal rather than from a provisioning script. For your first build upload and team invitations, see [Quick Start](/get-started/quick-start#upload-your-build).
:::
## systemd (Linux relay server)
Use systemd when the relay runs on a Linux host and you want it to start at boot, restart after crashes, and write logs to journald. Install tapflow globally first:
```sh
npm install -g tapflow
```
Create a dedicated user and data directory:
```sh
sudo useradd --system --create-home --home-dir /var/lib/tapflow --shell /usr/sbin/nologin tapflow
sudo mkdir -p /etc/tapflow /var/lib/tapflow/.tapflow/data
sudo chown -R tapflow:tapflow /var/lib/tapflow
```
Put relay secrets in `/etc/tapflow/relay.env`. The existing [JWT_SECRET](/operate/configure#jwt-secret) section also describes the `.env` convention that the relay reads directly from its data directory:
```ini
TAPFLOW_DATA_DIR=/var/lib/tapflow/.tapflow/data
JWT_SECRET=YOUR_JWT_SECRET
```
`TAPFLOW_HOME` alone would put the data in `/var/lib/tapflow/data`. `TAPFLOW_DATA_DIR` is named here anyway, so this unit matches a server set up before the install directory existed and keeps its data where it already is. To use the shorter layout on a fresh server, drop the line and change `/var/lib/tapflow/.tapflow/data` in the `mkdir` above to `/var/lib/tapflow/data`. As long as a `.tapflow/data` folder exists, even an empty one, the relay treats it as existing data and keeps using it.
Generate `JWT_SECRET` with `openssl rand -hex 32`, then keep `/etc/tapflow/relay.env` readable only by root:
```sh
sudo chmod 600 /etc/tapflow/relay.env
```
Create `/etc/systemd/system/tapflow-relay.service`:
```ini
[Unit]
Description=tapflow relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tapflow
Group=tapflow
WorkingDirectory=/var/lib/tapflow
Environment=TAPFLOW_HOME=/var/lib/tapflow
EnvironmentFile=/etc/tapflow/relay.env
ExecStart=/usr/bin/env tapflow relay start
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/tapflow
[Install]
WantedBy=multi-user.target
```
The hardening directives keep the filesystem read-only except for
`/var/lib/tapflow`, which contains the `TAPFLOW_DATA_DIR` set above. The relay
writes nowhere else, so nothing further needs opening — but if you move
`TAPFLOW_DATA_DIR`, add its new path to `ReadWritePaths` too.
With one exception: `ProtectHome=true` makes `/home`, `/root` and `/run/user`
unreachable outright, and `ReadWritePaths` cannot open a path back up inside
them. A data directory in any of those is invisible to the service no matter
what you list. Keep it elsewhere, or switch to `ProtectHome=read-only`. This is
also why the `tapflow` user is given a home in `/var/lib/tapflow` above rather
than under `/home`.
Place `tapflow.config.json` in `/var/lib/tapflow` if you need to customize the port or other settings, because `TAPFLOW_HOME` above makes that the install directory. Set the same variable in your own shell before running `tapflow` commands by hand, or they will read the default install in your home directory instead of the service's.
Run a smoke test before enabling the service, so a problem arrives as a message
you can read instead of a unit that restarts every five seconds. `systemd-run`
applies the same sandbox the unit will, which a plain `sudo -u tapflow` would
not — the directives above are what most often turn out to be the problem:
```sh
sudo systemd-run --pty --unit=tapflow-smoke --property=User=tapflow --property=Group=tapflow --property=WorkingDirectory=/var/lib/tapflow --property=Environment=TAPFLOW_HOME=/var/lib/tapflow --property=EnvironmentFile=/etc/tapflow/relay.env --property=NoNewPrivileges=true --property=ProtectSystem=strict --property=ProtectHome=true --property=PrivateTmp=true --property=ReadWritePaths=/var/lib/tapflow /usr/bin/env tapflow relay start
```
In another shell, confirm the relay answers. Give it the URL rather than relying
on the default: `tapflow status` reads *your* configuration to find the relay,
not the service's, so a port set in `/var/lib/tapflow/tapflow.config.json` is
one this shell does not know about.
Copy the address the command above printed on startup — `Relay : …`. `--relay`
accepts it as-is and switches the scheme itself, so a TLS deployment needs no
edit:
```sh
tapflow status --relay http://localhost:4000 # whatever `Relay :` said
```
Stop the foreground relay with Ctrl-C once the status check passes.
Enable and start the service:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now tapflow-relay
sudo systemctl status tapflow-relay
```
View logs with:
```sh
journalctl -u tapflow-relay -f
```
After updating the global package, restart the service:
```sh
npm update -g tapflow
sudo systemctl restart tapflow-relay
```
---
# https://www.tapflow.dev/operate/requirements
# Requirements
## Relay Server
- Node.js ≥ 22
- macOS or any server OS (the relay only routes traffic)
- ~512 MB RAM, 1 vCPU is sufficient
## Agent
The agent runs on macOS. iOS and Android can run together on the same Mac.
- macOS on **Apple Silicon** (M-series)
- Node.js ≥ 22
::: warning Intel Macs are not supported
The agent ships native helper binaries built for arm64 only, so an Intel (x86_64) Mac cannot run them.
Opening a build fails with a generic `spawn unknown error`.
Apple Silicon is what tapflow has been developed and verified against. Intel support is possible and
tracked in [#464](https://github.com/jo-duchan/tapflow/issues/464), but it is not scheduled — it needs
a universal build plus verification on hardware the maintainers do not have.
The relay has no such constraint. Only the machine running the **agent** needs Apple Silicon.
:::
### iOS
- macOS 26 (26.x) or 27 (27.x)
- Xcode 26 (26.x) or 27 (27.x) with iOS Simulator Runtime installed
::: tip Newer versions
When a new major version (e.g. Xcode 28) is released, supporting it is our top priority. Until that support lands, please stay on the verified versions above.
:::
### Android
- Android SDK (`adb` in `$PATH` or `ANDROID_HOME` set)
- An AVD using `google_apis/arm64-v8a` system image (android-35)
## Dashboard
- Any modern browser (Chrome, Firefox, Safari, Edge)
- No extensions or plugins required
::: tip When using Tailscale tunnel
Every device that needs dashboard access must have Tailscale installed. → [Tailscale setup](/operate/external-access#tailscale-recommended)
:::
---
# https://www.tapflow.dev/operate/scaling
# Scaling Mac Resources
tapflow scales horizontally — add more Mac hosts to the same relay to expand your device pool. Each Mac runs its own agent and connects outbound to the relay, so no firewall changes are required.
::: warning Every agent Mac must share the relay's LAN
Each Mac you add streams to the relay continuously, so it must be on the same LAN as the relay. See [deployment networking](/operate/deployment#deployment-scenarios) for the requirements.
:::
See [Introduction — How it works](/get-started/introduction#how-it-works) for a diagram.
## Adding a second Mac
::: tip The relay must be reachable from all Macs
When running `tapflow agent start` on another Mac, `ws://localhost:4000` resolves to that Mac's own localhost — not the relay machine. Use the relay's local IP address (`ws://192.168.x.x:4000`).
:::
On the new Mac, install tapflow and point it at your existing relay. The relay runs on a different machine, so an `agent`-scope token is required ([Remote relay authentication](/operate/agents#remote-relay-authentication)):
```sh
npm install -g tapflow
tapflow agent start --relay ws://192.168.x.x:4000 --token tflw_pat_xxxxxxxx
```
That's it. The new Mac registers itself and its devices become visible in the dashboard immediately.
## Agent names
Each agent uses the Mac's hostname as its display name in the dashboard. To see which agent is which:
```sh
tapflow status
```
```
● agent ◉ in use ○ idle
● mac-mini-office.local (iOS)
○ iPhone 16 Pro
○ iPhone 15
● mac-mini-lab.local (iOS)
○ iPhone 14
● mac-mini-lab.local (Android)
○ tapflow-phone
3 agent(s) · 4 device(s) · 0 active session(s)
```
The agent name is Node's `os.hostname()`, which on macOS is usually the local hostname ending in `.local`. When one Mac runs both an iOS and an Android agent, the name appears twice, told apart by the `(iOS)` or `(Android)` after it. To change it, edit **Local hostname** under **System Settings → General → Sharing**.
## Simulators per Mac
iOS Simulator and Android Emulator are memory-intensive. Each Mac can typically run 2–4 simultaneously depending on available RAM.
Simulators are booted and managed through the dashboard. The agent reports every available device to the relay, booted or not, and boots one on demand when a teammate starts a session.
## Monitoring
The dashboard's **Mac Resources** page (`/mac-resources`) shows CPU and RAM usage for each Mac agent, so you can spot an overloaded host before more sessions land on it.
| Element | Description |
|---|---|
| Mac list | Macs that are connected now or have reported usage in the last 30 days, by hostname. A green dot marks a Mac whose agent is connected; select a Mac to show its charts. |
| Time-series chart | Historical CPU % (blue) and RAM % (purple). |
| Range selector | **1h** / **6h** / **24h** / **7d** — switches the visible window. |
Data is sampled once per minute and retained for 30 days.
To check from the terminal which agents and devices are connected (it does not report CPU or RAM):
```sh
tapflow status
```
---
# https://www.tapflow.dev/operate/streaming-quality
# Streaming Quality
tapflow streams each device screen as H.264 and decodes it in the browser. The resolution and decoder aren't fixed — tapflow picks one of three **profiles** per viewer, based on how that viewer connects, balancing image sharpness against decode cost for the path.
You don't select a profile. It follows from your relay deployment and the viewer's network, so the same device can serve a localhost-grade stream to one teammate and a bandwidth-trimmed stream to another on the road.
## Profiles
| Profile | Connection | Resolution | Decoder | Experience |
|---------|------------|------------|---------|------------|
| **Standard** *(recommended)* | LAN over HTTP | 1280 px | WASM (tinyh264) | Near-localhost responsiveness |
| **Smooth** | LAN over HTTPS *(or localhost)* | Native | WebCodecs (hardware) | Localhost-grade |
| **Remote** | External address | 1000 px | WebCodecs (hardware); WASM over plain HTTP | Usable QA threshold |
**Standard** is what most teams use day to day — a plain-HTTP relay on the LAN. The browser decodes H.264 with the software WASM decoder, so tapflow caps the resolution at 1280 px to keep decode load low while keeping responsiveness close to localhost.
**Smooth** is the best tapflow can offer. On a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — HTTPS on the LAN, or localhost — the browser unlocks WebCodecs and decodes in hardware, so the agent sends native resolution at minimal CPU cost.
**Remote** covers viewers the relay sees arriving from a non-private address, such as a public IP. The link is assumed to be bandwidth-constrained, so the resolution is trimmed to 1000 px. HTTPS keeps hardware decoding; plain HTTP uses the WASM decoder. Enough for QA, at the edge of comfortable.
## How your deployment maps to a profile
The profile is decided by how the browser reaches the relay — which is exactly what you choose when you [self-host the relay](/operate/deployment).
| Your setup | Profile |
|------------|---------|
| Relay on the LAN over plain HTTP | **Standard** |
| Relay on the LAN over HTTPS | **Smooth** |
| HTTPS through a tunnel (VPS + rathole, `tailscale serve`) | **Smooth** |
| Direct connection to the relay port from a public IP or a tailnet address (`100.x`) | **Remote** |
A tunnel client connects over loopback from inside the relay Mac, so the relay cannot tell a viewer arriving through the tunnel from a local one. Viewers on an HTTPS tunnel therefore get native resolution. Tailscale's tailnet addresses, on the other hand, are not in the private-range list and count as external: opening the default plain-HTTP Tailscale URL gives a 1000 px stream decoded by the WASM decoder. If tunnel viewers are short on bandwidth, set a cap yourself with `TAPFLOW_MAX_SIZE` below.
To move a shared LAN from **Standard** to **Smooth**, serve the relay over HTTPS — see the [certificate method in Configuring tapflow](/operate/configure#_3-certificate-method-when-smooth-is-chosen).
::: tip Why HTTPS unlocks hardware decoding
WebCodecs is only available in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). Plain HTTP on the LAN is not secure, so the browser falls back to the WASM decoder — which is why **Standard** caps resolution and **Smooth** (HTTPS) doesn't.
:::
## Tuning the resolution
The profile is automatic, but you can override the resolution caps. Set these on the Mac running the agent.
| Variable | Default | Description |
|----------|---------|-------------|
| `TAPFLOW_MAX_SIZE` | *(per profile)* | Cap for all platforms (px, longest side). `0` forces native resolution on every connection. |
| `TAPFLOW_MAX_SIZE_LAN` | `1280` | Standard (LAN HTTP) cap. |
| `TAPFLOW_MAX_SIZE_EXTERNAL` | `1000` | Remote (external) cap. |
| `TAPFLOW_IOS_MAX_SIZE` | *(per profile)* | iOS-specific override. Takes precedence over `TAPFLOW_MAX_SIZE`. |
| `TAPFLOW_ANDROID_MAX_SIZE` | *(per profile)* | Android-specific override. Takes precedence over `TAPFLOW_MAX_SIZE`. |
| `TAPFLOW_ANDROID_FPS` | `30` | Frame-rate cap for the Android stream. |
Stream quality also depends on a stable, low-latency link between the agent and the relay. For agent placement and network requirements, see [Agent Setup](/operate/agents).
---
# https://www.tapflow.dev/operate/team-and-roles
# Team, roles & tokens
The dashboard's **Settings** has three sub-pages accessible from the left nav.
## Default
The page every member opens first. Its profile settings (nickname, avatar, password) are the same for everyone and are described in [For teammates](/get-started/teammates#profile). Three more sections appear by role:
- **Workspace** — the team name and logo. Visible to Admins only.
- **Flow run videos** — how long passed flows' videos are kept and how much storage videos may use, with what is in use now. Visible to Admins only. See [Flow Runs](/automation/runs#recording).
- **Apps** — rename or delete apps. Visible to Admins, Developers and QA, hidden from Viewers.
## Team
Visible to **Admin** only.
- **Members list** — all accounts with email, role, and join date.
- **Invite member** — send an email invite or generate a copy-paste link. Invites expire after 7 days.
- **Change role** — reassign any member's role (Admin / Developer / QA / Viewer).
- **Remove member** — permanently deletes the account. The member is signed out everywhere at once, and their open device sessions and any agents connected with their tokens are disconnected. You cannot remove yourself.
- **Reset pwd** — create a password reset link for a member. The link appears in a dialog to copy and share, and is also emailed when SMTP is configured. It works once, for 2 hours, and making a new one turns off the earlier one. The reset signs the member out everywhere and revokes all of their personal access tokens, CI and agent tokens included, so agents using those tokens disconnect until new tokens are issued. This matters most when an Admin resets their own account, because agent tokens belong to Admins. A password change in **Settings** keeps tokens.
## Invite teammates {#invite-teammates}
Once signed in as Admin, go to **Settings → Team** and create invite links:
1. Click **Invite member**.
2. Enter the team member's email and select a role. The role defaults to **QA**.
- **Admin** — can do everything except remove their own account. Inviting members, changing roles, removing members, resetting passwords, workspace settings and issuing **Agent** tokens are Admin-only.
- **Developer**, **QA** — can add, edit and delete apps, upload builds, change a build's status, schedule build deletion, manage webhooks and create their own API tokens in **Settings → Tokens**. The two roles have the same permissions.
- **Viewer** — read-only. Can view builds, test them on a simulator or emulator in a QA Session, and comment. Cannot change builds or apps, and can neither see nor change webhooks.
Commenting and starting sessions are open to every signed-in member, whatever their role. A role change applies right away to the API endpoints that check roles, without the member signing in again.
3. Click **Generate invite link**. The link appears in the dialog, and is also copied to your clipboard when the browser allows it. If SMTP is configured, the member also receives an invite email with a link to set their password. An email that already belongs to a member is refused, and the dialog says so under the email field.
When you opened the dashboard at `localhost` on the relay host, the invite link carries the relay host's LAN address. A relay inside a Docker container cannot know its LAN address, so the link uses the address in your browser's address bar. When a tunnel or `relay.url` is configured, the link uses that address. If SMTP isn't configured, copy the link shown in the dialog and share it directly; see [Configuration](/reference/configuration) to set up SMTP. What an invited teammate does next is in [For teammates](/get-started/teammates).
## Tokens
Personal access tokens (PATs) for CI/CD scripts and API access. The sidebar shows this page to **Admin**, **Developer** and **QA**; each member sees and revokes only their own tokens. The **Agent** type is offered to Admins only.
- **New token** — enter a name, an **Expiration**, and a Type. Choose 7, 30, 60 or 90 days, a custom number (1–365 days), or **No expiration**; the default is 30 days. A token with no expiration stays valid until you revoke it, so keep CI tokens to 90 days or less. The list marks these tokens **No expiration** so you can find and clean them up. **API** is for CI uploads and API access (scope `view, builds:write`); **Agent** connects remote Mac agents. The token is shown once — copy it immediately.
- **Revoke** — instantly invalidates the token.
Use PATs with the `Authorization: Bearer tflw_pat_` header to upload builds from CI. See [Upload from CI](/operate/ci-distribution).
---
# https://www.tapflow.dev/operate/webhooks
# Webhooks
When your team finishes a review and moves a build to `Done` or `Rejected`, tapflow notifies a URL you registered. Wire that signal into a Slack alert or the next deploy step, and review outcomes flow through automatically.
tapflow doesn't build your app, so what it reports here isn't a build completion — it's the **review verdict a person made**.
## How it works
```
Someone reviews the build in App Center
→ moves status to Done / Rejected
→ tapflow POSTs to your registered URL (signed metadata)
→ your receiver fires a Slack alert · the next CI step
```
::: info Two testing paths
This guide covers the **manual review path**: CI delivers the build; people do the testing.
For automated testing where an LLM agent controls the simulator, see [MCP in CI/CD](/automation/mcp-ci). That is a separate, experimental feature.
:::
## Register an endpoint
There are two ways to register. Declare endpoints in `tapflow.config.json` if you manage settings as files, or use the REST API to add and remove them at runtime. Endpoints from both sources are delivered together.
### Declare in tapflow.config.json (recommended) {#declare-in-config-json-recommended}
Add entries to the `webhooks` array in `tapflow.config.json`. This keeps webhooks in the same file a self-hosted operator already uses for TLS, SMTP, and the rest.
```json
{
"webhooks": [
{ "url": "https://ci.internal/hooks/tapflow", "secretEnv": "TAPFLOW_WEBHOOK_SECRET_CI" }
]
}
```
Secrets never go in `tapflow.config.json`. Point `secretEnv` at an environment variable name and tapflow reads that value as the signing key. Keep the actual secret in `.env`.
```
TAPFLOW_WEBHOOK_SECRET_CI=a-long-random-string
```
| Field | Description |
|-------|-------------|
| `url` | Destination that receives the POST (required) |
| `secretEnv` | Name of the env var holding the signing secret (optional, strongly recommended) |
| `enabled` | Whether the endpoint is active (defaults to `true`) |
Changes to `tapflow.config.json` take effect after a relay restart.
### Register via the REST API
To add one at runtime, use `POST /api/v1/webhooks`. Authentication is the same as build upload — a personal access token (PAT) with the `builds:write` scope. See [Build Distribution](/operate/ci-distribution) for token generation.
```sh
curl -X POST https://your-relay/api/v1/webhooks \
-H "Authorization: Bearer $TAPFLOW_PAT" \
-H "Content-Type: application/json" \
-d '{"url":"https://ci.internal/hooks/tapflow","secret":"a-long-random-string"}'
```
| Field | Description |
|-------|-------------|
| `url` | Destination that receives the POST (required) |
| `secret` | Key used to sign deliveries (optional, strongly recommended) |
| `enabled` | Whether the endpoint is active (defaults to `true`) |
Unlike `tapflow.config.json`, the REST API takes the secret directly in the request body. Register several and every enabled endpoint receives its own POST — connect Slack and an internal CI hook at the same time.
The REST management endpoints:
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/webhooks` | Register an endpoint |
| `GET` | `/api/v1/webhooks` | List endpoints (`secret` is never returned) |
| `PATCH` | `/api/v1/webhooks/:id` | Update `url` · `secret` · `enabled` |
| `DELETE` | `/api/v1/webhooks/:id` | Delete an endpoint |
## Payload
The delivered body is JSON:
```json
{
"event": "build.status_changed",
"build": {
"id": "42",
"platform": "ios",
"appVersion": "1.4.0",
"status": "Done"
},
"changedAt": "2026-07-03T10:00:00.000Z"
}
```
| Field | Description |
|-------|-------------|
| `event` | Event type. Currently always `build.status_changed` |
| `build.id` | Build identifier |
| `build.platform` | `ios` or `android` |
| `build.appVersion` | App version, or `null` when unknown |
| `build.status` | `Done` or `Rejected` |
| `changedAt` | When the status changed (ISO 8601) |
The body carries build identification only — no app binary or screen data.
## Verifying the signature
If you set a `secret` at registration, tapflow signs the body with HMAC-SHA256 and sends it in the `X-Tapflow-Signature` header, hex-encoded with a `sha256=` prefix. Re-sign the body with the same secret and compare — a match proves the request came from tapflow and the body wasn't tampered with.
```js
import crypto from 'crypto'
function isFromTapflow(rawBody, signature, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(signature ?? '')
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```
Always verify against the raw, unparsed body — parsing the JSON and re-serializing it changes the bytes and breaks the signature.
## When it fires
A webhook is sent **only when** `status_label` changes to `Done` or `Rejected`.
- A request that leaves the value unchanged — re-setting a `Done` build to `Done` — sends nothing.
- Changes to `Backlog` or `In Progress`, or updates that touch only other fields, send nothing.
- Delivery is best-effort. If your receiver is down or fails, the status change itself still succeeds, and each request times out after 5 seconds.
| Status | Meaning |
|--------|---------|
| `Done` | Stakeholders approved |
| `Rejected` | Issues found, needs fixes |
## Security
- The payload carries metadata only; app binaries are never sent.
- Webhooks are never sent to loopback (`127.0.0.1`, `localhost` and names under it) or cloud-metadata (`169.254.169.254`) addresses. Registration rejects those URLs, and each delivery checks the addresses the hostname resolves to at that moment and connects only to an allowed one, so a name that points only at them is refused too.
- Private LAN addresses (`10.x`, `192.168.x`, …) are allowed for self-hosted CI. Anyone who can manage webhooks can therefore have the relay send a request to a LAN address; see [Access control boundaries](/reference/security#access-control-boundaries).
- Redirects are not followed. If your receiver answers with a redirect, register the URL it redirects to.
- A URL containing a username or password is rejected. Use `secret` to authenticate deliveries instead.
- `secret` is optional, but set one — an exposed URL can otherwise receive forged requests.
## Getting a build in
This is an outbound notification tapflow sends. The opposite direction — getting a build *into* tapflow in the first place — is a normal build upload, covered in [Build Distribution](/operate/ci-distribution).
---
# https://www.tapflow.dev/reference/api
# REST API
All endpoints are served by the relay at `http(s):///api/v1/`.
**Authentication**
- Dashboard users: session cookie (`tapflow_token`, set automatically on login)
- CI/CD scripts: `Authorization: Bearer tflw_pat_` header
Only the endpoints below accept a personal access token (PAT), and they accept the session cookie too.
| PAT scope | Endpoints |
|-----------|-----------|
| `builds:write` | `POST /builds`, `GET /builds`, `GET /builds/:id`, `POST /comments`, every webhook endpoint, writing run records (`POST` under `/runs`) |
| `view` | `GET /apps`, `GET` under `/runs`, `GET /sessions/:sessionId/screenshot`, `GET /sessions/:sessionId/ui-tree`, files under `/uploads/` (at the relay root, not under `/api/v1/`), device-session WebSockets opened from a remote machine |
| `agent` | A remote agent's WebSocket connection, accepted only while the member who issued the token is an Admin |
**Roles.** A call made with a PAT is held to its owner's current role. Viewer is read-only: uploading, updating or scheduling deletion of a build, creating, renaming or deleting an app, every webhook endpoint and writing run records return `403` for a Viewer. Reading builds and posting comments work for a Viewer too. The endpoints that check roles read the role on every request, so a role change applies there without signing in again or issuing a new token.
## Error responses
All errors return JSON in the form `{ "error": "..." }`.
| Status | Meaning | Example |
|--------|---------|---------|
| `400` | Bad request (missing field, invalid format, etc.) | `{ "error": "file required" }` |
| `401` | Not authenticated or session expired | `{ "error": "Unauthorized" }` |
| `403` | Forbidden | `{ "error": "Forbidden" }`, `{ "error": "Insufficient scope" }` or `{ "error": "Viewers have read-only access" }` |
| `404` | Resource not found | `{ "error": "Build not found" }` |
| `409` | Not possible in the current state | `{ "error": "Device is not booted" }` |
| `410` | Token expired | `{ "error": "Invitation expired or not found" }` |
| `429` | Too many requests (with a `Retry-After` header) | `{ "error": "Too many attempts. Try again later." }` |
| `500` | Server error | `{ "error": "Internal server error" }` |
| `502` | The agent is unreachable or returned an error | `{ "error": "Agent offline" }` |
| `504` | The agent did not answer in time | `{ "error": "Screenshot timed out" }` |
Deleting a comment, a member or a token returns `204` with no body. Deleting an app or a webhook, and cancelling a scheduled build deletion, return `200 { "ok": true }`.
## Auth
### `GET /api/v1/auth/status`
Whether the relay has an admin account yet. No authentication.
**Response `200`**
```json
{ "initialized": false, "canInitialize": true }
```
`canInitialize` is `true` when no account exists and this request comes from the relay host, the only place the first account can be created from. The setup page uses it to show the `tapflow admin init` instruction instead of the form.
### `POST /api/v1/auth/init`
Create the first admin account. Only works when no accounts exist yet.
```
Body (JSON):
email string required
password string required (min 8 chars)
```
**Response `201`**
```json
{ "ok": true }
```
Returns `403 { "error": "Already initialized" }` if an account already exists, and `403` with an error naming `tapflow admin init` if the request does not come from the relay host.
### `POST /api/v1/auth/login`
Sign in. Sets the `tapflow_token` cookie on success (valid for 7 days).
```
Body (JSON):
email string required
password string required
```
**Response `200`**
```json
{ "ok": true, "role": "Admin" }
```
The email is matched without regard to letter case or surrounding spaces. Repeated failed sign-ins from the same address for the same email return `429` with a `Retry-After` header.
### `POST /api/v1/auth/logout`
Sign out. Clears the session cookie.
**Response `200`**
```json
{ "ok": true }
```
### `GET /api/v1/auth/me`
Return the currently signed-in user's info.
**Response `200`**
```json
{
"id": 1,
"email": "admin@example.com",
"displayName": "Admin",
"avatarUrl": "/uploads/avatars/user-1.png",
"role": "Admin"
}
```
`avatarUrl` is `null` when there is no profile image.
### `POST /api/v1/auth/change-password`
Change the current user's password.
```
Body (JSON):
currentPassword string required
newPassword string required (min 8 chars)
```
**Response `200`**
```json
{ "ok": true }
```
The response sets a new `tapflow_token` cookie. Changing the password signs the user out of every other browser, and closes their open connections.
## Invitations
### `GET /api/v1/invitations/verify`
Check whether an invitation token is valid.
```
Query:
token string required (64-char hex)
```
**Response `200`**
```json
{ "role": "QA" }
```
Returns `410` if expired or not found.
### `POST /api/v1/invitations/accept`
Accept an invitation and create an account. Sets a login cookie on success. Returns `409` when the invitation's email already has an account; an invitation never changes an existing account.
```
Content-Type: multipart/form-data
Fields:
token string required
password string required (min 8 chars)
display_name string optional
File:
avatar image (PNG/JPEG, max 2 MB) — optional
```
**Response `200`**
```json
{ "ok": true }
```
## Password reset
### `GET /api/v1/auth/reset-password/verify`
Check whether a password reset token is valid.
```
Query:
token string required
```
**Response `200`**
```json
{ "ok": true }
```
A reset token is valid for 2 hours after it is issued. Returns `410` if expired.
### `POST /api/v1/auth/reset-password`
Reset the password.
```
Body (JSON):
token string required
password string required (min 8 chars)
```
**Response `200`**
```json
{ "ok": true }
```
The user is signed out of every browser and signs in again with the new password. Their personal access tokens are revoked, and agents connected with them are disconnected; the user issues new ones.
### `POST /api/v1/team/members/:id/send-reset`
Create a password reset link for a specific member, and email it when SMTP is configured. **Admin only**.
**Response `200`**
```json
{ "ok": true, "emailSent": true, "token": "abc123...", "resetUrl": "http://192.168.0.10:4000/reset-password?token=abc123..." }
```
The link works once, for 2 hours. Creating a new one turns off the member's earlier links. `emailSent` is `false` when SMTP is not configured, and for a member invited without an email. `resetUrl` follows the same rules as `inviteUrl` in [`POST /api/v1/team/invite`](#post-api-v1-team-invite). When it is `null`, build the link as `/reset-password?token=`.
## Apps
### `GET /api/v1/apps`
Return all apps. Each app includes a summary of its latest build.
**Response `200`**
```json
{
"items": [
{
"id": 7,
"name": "My App",
"bundle_id_key": "com.example.app",
"platform": "ios",
"created_at": "2025-05-01T00:00:00.000Z",
"latest_build_id": 42,
"version_name": "1.2.3",
"build_number": "89",
"status_label": "In Progress",
"latest_uploaded_at": "2025-05-15T12:00:00.000Z"
}
]
}
```
### `POST /api/v1/apps`
Create an app manually. Requires the **Admin, Developer or QA** role. A Viewer gets `403`. If an app with the same `bundle_id_key` already exists, on any platform, you get `409`. Create an app for iOS and Android once, with `both`.
```
Body (JSON):
name string required
bundle_id_key string required
platform ios|android|both required
```
**Response `201`**
```json
{ "id": 7, "ok": true }
```
### `PATCH /api/v1/apps/:id`
Rename an app. Requires the **Admin, Developer or QA** role. A Viewer gets `403`.
```
Body (JSON):
name string required
```
**Response `200`**
```json
{ "ok": true }
```
### `DELETE /api/v1/apps/:id`
Delete an app and all its builds, with their comments, recordings, run records and files. Requires the **Admin, Developer or QA** role. A Viewer gets `403`.
**Response `200`**
```json
{ "ok": true }
```
## Builds
### `POST /api/v1/builds`
Upload a build. A Viewer gets `403`, whether it calls with the cookie or with its own PAT.
```
Content-Type: multipart/form-data
Authorization: Bearer tflw_pat_ (or session cookie)
```
Only `file` is required; every other field is optional.
| Field | Required | Description |
|-------|----------|-------------|
| `file` | Yes | The build artifact. iOS: `.app.zip` or `.tar.gz` / `.tgz` (a simulator build); Android: `.apk`. Max 500 MB by default (`TAPFLOW_MAX_BUILD_BYTES` changes it). `.ipa` and `.aab` are rejected. |
| `status` | No | Initial review status — one of `Backlog`, `In Progress`, `Done`, `Rejected`. Omit to leave it unset. |
| `label` | No | Free-text label to identify the build in App Center (e.g. a branch name or `rc-1`). |
| `platform` | No | `ios` or `android`. Derived from the file type when omitted. |
| `app_id` | No | Attach to an existing app explicitly. Normally the app is resolved automatically from the bundle ID. |
::: warning iOS builds
`.ipa` files are not supported. Upload `.app.zip`, or the `.tar.gz` / `.tgz` a cloud simulator build produces. Build `.app.zip` with `xcodebuild -sdk iphonesimulator` and zip the `.app` folder.
:::
**Response `201`**
```json
{
"id": 42,
"app_id": 7,
"name": "My App",
"version_name": "1.2.3",
"build_number": "89",
"bundle_id": "com.example.app",
"status_label": "In Progress",
"platform": "ios",
"uploaded_at": "2025-05-15T12:00:00.000Z"
}
```
### `GET /api/v1/builds`
Return a paginated list of builds.
```
Query:
page number page number (default: 0)
limit number page size (default: 20, max: 100)
q string search by version name
platform ios|android platform filter
status Backlog|In Progress|Done|Rejected status filter
app_id number filter by app
sort uploaded_at|version_name|status_label sort field (default: uploaded_at)
dir asc|desc sort direction (default: desc)
```
**Response `200`**
```json
{
"items": [ { ... } ],
"total": 128
}
```
### `GET /api/v1/builds/:id`
Return a single build.
**Response `200`**
```json
{
"id": 42,
"app_id": 7,
"name": "My App",
"version_name": "1.2.3",
"build_number": "89",
"version_label": "rc-1",
"status_label": "In Progress",
"platform": "ios",
"bundle_id": "com.example.app",
"uploaded_at": "2025-05-15T12:00:00.000Z",
"completed_at": null,
"delete_after": null
}
```
`delete_after` is the time the build's files are purged, or `null` when no deletion is scheduled. It is independent of `status_label` — marking a build `Done` does not schedule deletion.
### `PATCH /api/v1/builds/:id`
Update the status or label of a build. A Viewer gets `403`.
```
Body (JSON):
status_label Backlog|In Progress|Done|Rejected|null optional
version_label string|null optional
```
**Response `200`**
```json
{ "ok": true }
```
### `POST /api/v1/builds/:id/schedule-deletion`
Schedule the build for deletion. The server sets `delete_after = now + TAPFLOW_BUILD_TTL_DAYS`; the files and record are purged after that time. A Viewer gets `403`.
**Response `200`**
```json
{ "ok": true, "delete_after": "2025-05-22 12:00:00" }
```
### `DELETE /api/v1/builds/:id/schedule-deletion`
Cancel a scheduled deletion, clearing `delete_after`. A Viewer gets `403`.
**Response `200`**
```json
{ "ok": true }
```
## Webhooks
Manage the endpoints notified when a build's review status changes. Every call takes the session cookie or a PAT with the `builds:write` scope. A webhook URL is often a secret in itself, so a Viewer gets `403` from every webhook endpoint, listing included. Payloads and signature verification are covered in [Webhooks](/operate/webhooks).
### `GET /api/v1/webhooks`
Return the registered webhooks. The secret itself is never returned; `has_secret` says whether one is set.
**Response `200`**
```json
{
"webhooks": [
{ "id": 1, "url": "https://ci.internal/hooks/tapflow", "enabled": true, "has_secret": true, "created_at": "2025-05-01 00:00:00" }
]
}
```
### `POST /api/v1/webhooks`
Register a webhook.
```
Body (JSON):
url string required
secret string|null optional (HMAC signing secret)
enabled boolean optional (default: true)
```
**Response `201`**: the registered webhook, in the same shape as an item in the `GET` list
### `PATCH /api/v1/webhooks/:id`
Update whichever of `url`, `secret` and `enabled` the body carries.
**Response `200`**: the updated webhook. `400` when there is nothing to update, `404` when the webhook does not exist.
### `DELETE /api/v1/webhooks/:id`
Delete a webhook.
**Response `200`**
```json
{ "ok": true }
```
## Comments
### `GET /api/v1/comments`
Return comments for a build.
```
Query:
build_id number required
```
**Response `200`**
```json
[
{
"id": 1,
"body": "Login button is not tappable",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "Kim QA",
"authorAvatarUrl": "/uploads/avatars/user-3.png",
"attachments": [
{ "id": 3, "file_path": "/uploads/comments/...", "mime": "image/png" }
]
}
]
```
`author` is the author's display name, or the part of their email before `@` when they have none. `authorAvatarUrl` is `null` when there is no profile image.
### `POST /api/v1/comments`
Post a comment. Supports image attachments. Call it with the session cookie or a PAT with the `builds:write` scope. Every role can post, Viewer included.
```
Content-Type: multipart/form-data
Fields:
build_id number required
body string required
File:
attachment image (PNG/JPEG/WebP, max 5 MB) — optional
```
**Response `201`**
```json
{
"id": 1,
"body": "Login button is not tappable",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "Kim QA"
}
```
`author` here is the display name as stored, so it is `null` when the author has none.
### `DELETE /api/v1/comments/:id`
Delete a comment. Only the author or an Admin can delete.
**Response `204`** (no body)
## Team
### `GET /api/v1/team/members`
Return all members. **Admin only**.
**Response `200`**
```json
[
{
"id": 1,
"email": "admin@example.com",
"display_name": "Admin",
"role": "Admin",
"joined_at": "2025-05-01T00:00:00.000Z"
}
]
```
### `POST /api/v1/team/invite`
Invite a team member. **Admin only**. Invitations expire after **7 days**. Returns `409` when `email` already belongs to a member, whatever its letter case.
```
Body (JSON):
email string optional (omit to send no invitation email)
role Admin|Developer|QA|Viewer optional (default: QA)
```
**Response `201`**
```json
{ "token": "abc123...", "emailSent": true, "inviteUrl": "http://192.168.0.10:4000/invite?token=abc123..." }
```
If SMTP is not configured or `email` is omitted, `emailSent: false` is returned. When `inviteUrl` is not `null`, it is the link the invitation email carries. It is built from the tunnel's `publicUrl`, otherwise `relay.url` (`TAPFLOW_RELAY_URL`). When `tapflow start` or `tapflow relay start` runs the tunnel, that is the address the tunnel got, including one Tailscale detected. A standalone relay uses the configured value. An `http://` tunnel address is not used when the relay serves HTTPS. `inviteUrl` is `null` when there is no candidate, or when the only one is an address a teammate cannot open, such as `localhost`. In that case, build the link from the address teammates use to reach the relay: `/invite?token=`.
### `PATCH /api/v1/team/members/:id`
Change a member's role. **Admin only**.
```
Body (JSON):
role Admin|Developer|QA|Viewer required
```
**Response `200`**
```json
{ "ok": true }
```
### `DELETE /api/v1/team/members/:id`
Delete a member. **Admin only**. You cannot delete yourself.
**Response `204`** (no body)
## Tokens (Personal Access Tokens)
### `GET /api/v1/tokens`
Return the current user's PAT list.
**Response `200`**
```json
[
{
"id": 1,
"name": "GitHub Actions",
"scope": "builds:write",
"last_used_at": "2025-05-15T12:00:00.000Z",
"expires_at": null,
"created_at": "2025-05-01T00:00:00.000Z"
}
]
```
### `POST /api/v1/tokens`
Create a PAT. The token value is returned **only once** at creation time.
```
Body (JSON):
name string required
expires_in_days number optional (omit or send 0 for no expiry)
scope string optional (comma-separated; default: view,builds:write)
```
`expires_in_days` takes a number of days, 0 or more (a numeric string such as `"30"` works too). A negative value, anything that is not a number (including an empty string), or a count too large to be a date returns `400`. The dashboard accepts 1–365 days, but the API has no upper limit.
`scope` accepts `view`, `builds:write` and `agent`. The `agent` scope is what an agent on a remote Mac uses to connect to the relay, and only an Admin can issue it; any other role gets `403`.
**Response `201`**
```json
{ "token": "tflw_pat_abc123..." }
```
### `DELETE /api/v1/tokens/:id`
Immediately revoke a PAT.
**Response `204`** (no body)
## Profile
### `PATCH /api/v1/profile`
Update the current user's profile.
```
Content-Type: multipart/form-data
Fields:
display_name string optional
File:
avatar image (PNG/JPEG, max 2 MB) — optional
```
**Response `200`**
```json
{ "ok": true }
```
## Settings {#settings}
### `GET /api/v1/settings`
Return team settings. Requires sign-in.
**Response `200`**
```json
{
"team_name": "My Team",
"logo_url": "/uploads/team/logo.png",
"video_pass_retention_days": 3,
"video_storage_cap_mb": 20480,
"video_usage_bytes": 5368709120,
"video_effective_cap_bytes": 21474836480
}
```
`logo_url` is `null` when there is no logo. The `video_*` fields are the [flow run videos](#run-videos) policy: `video_usage_bytes` is what videos use now, and `video_effective_cap_bytes` the smaller of the storage setting and the room the disk actually has.
### `PATCH /api/v1/settings`
Update team settings. **Admin only**.
```
Content-Type: multipart/form-data
Fields:
team_name string optional
video_pass_retention_days number optional Days passed flows' videos are kept, 0–365
video_storage_cap_mb number optional Storage for videos in MB, 100–10485760
File:
logo image (PNG/JPEG, max 2 MB) — optional
```
**Response `200`**
```json
{ "ok": true }
```
## Recordings
### `POST /api/v1/recordings/upload`
Upload a recording file. Automatically deleted **72 hours** after upload.
```
Content-Type: multipart/form-data
Query:
sessionId string optional
buildId number optional
File:
(any field name) video file (webm, etc.) required
```
**Response `200`**
```json
{ "url": "/api/v1/recordings/abc123.webm" }
```
### `GET /api/v1/recordings`
Return a list of recordings.
```
Query:
buildId number optional
```
**Response `200`**
```json
[
{
"id": 1,
"url": "/api/v1/recordings/abc123.webm",
"sessionId": "sess_xxx",
"fileSize": 1048576,
"mime": "video/webm",
"createdAt": "2025-05-15T12:00:00.000Z",
"expiresAt": "2025-05-18T12:00:00.000Z"
}
]
```
### `GET /api/v1/recordings/:filename`
Download a recording file. Returns `404` for expired files.
## Runs {#runs}
The record `tapflow flow run` leaves for each run. The CLI writes it with its token and the dashboard's [Flow Runs](/automation/runs) page reads it. You rarely call these yourself, but the read endpoints are there if you build your own CI dashboard.
The write endpoints (`POST`) need the `builds:write` scope and a role that may write, and only the user who created a run may write to it; a run created with a PAT takes writes from that PAT only. Another user or token gets `403`, and a write to a finished run gets `409`. JSON bodies are capped at 1 MB.
A run with a build is deleted with that build; a run created without one is deleted after 7 days.
### `POST /api/v1/runs`
Create a run record.
```
Body (JSON):
client string required The runner's WebSocket ?client= value. The run counts as in progress while that socket is open
flows array required The flows to run [{ name, file? }], at most 500
buildId number optional The build under test
ci object optional { provider?, branch?, commit?, jobUrl? }. jobUrl is kept only for an http(s) address
```
**Response `201`**
```json
{ "id": "3f2b0c7e-8a41-4b5e-9d0f-1c2d3e4f5a6b" }
```
### `POST /api/v1/runs/:id/flows/:idx`
Report the result of flow `idx`. Reporting the same flow again replaces it.
```
Body (JSON):
status "passed" | "failed" required
durationMs number required
steps array required [{ index, name, status: "passed"|"failed"|"skipped", durationMs, message? }], at most 1000
failureKind string optional "product" | "environment"
failureMessage string optional
deviceId, deviceName, platform string optional
```
**Response `200`** `{ "ok": true }`
### `POST /api/v1/runs/:id/flows/:idx/screenshot`
Upload the screenshot of a reported flow: the screen it failed on, or the screen a passed flow ended on. The body is the image bytes as they are; the relay tells PNG from JPEG by the first bytes. Over 5 MB gets `413`, anything that is not an image gets `415`.
**Response `200`** `{ "ok": true }`
### `POST /api/v1/runs/:id/finish`
Finish the run.
```
Body (JSON):
status "passed" | "failed" | "aborted" required
exitCode number required
failureKind string optional
errorMessage string optional For example why the run failed before any flow started
```
**Response `200`** `{ "ok": true }`
### `GET /api/v1/runs`
List run records, newest first. Call it with the session cookie or a `view`-scope PAT.
```
Query:
ci "1" | "0" optional CI runs only / local runs only
status string optional running | passed | failed | aborted
flow string optional Part of a flow's name, ignoring the case of ASCII letters
build number optional
before string optional nextCursor from a previous answer: the page of older runs
after string optional prevCursor from a previous answer: the page of newer runs
limit number optional Default 50, at most 100
```
A `build`, `before` or `after` that is not a non-negative integer gets `400`. An empty value counts as not given.
**Response `200`**
```json
{
"items": [
{
"id": "3f2b0c7e-8a41-4b5e-9d0f-1c2d3e4f5a6b",
"status": "failed",
"finishedBy": "client",
"failureKind": "product",
"errorMessage": null,
"exitCode": 1,
"isCi": true,
"ci": { "provider": "github", "branch": "main", "commit": "abc1234", "jobUrl": "https://github.com/acme/app/actions/runs/1" },
"build": { "id": 7, "appName": "Coffee", "versionName": "1.2.0", "buildNumber": "42", "platform": "ios" },
"createdBy": "ci-bot",
"flowsTotal": 2,
"flowsPassed": 1,
"flowsFailed": 1,
"createdAt": "2026-10-11 01:00:00",
"finishedAt": "2026-10-11 01:01:05"
}
],
"nextCursor": null,
"prevCursor": null
}
```
`finishedBy: "relay"` means the runner disconnected before it said the run was over, and the relay closed the run.
### `GET /api/v1/runs/:id`
One run with each flow's result. It has the list item's fields plus:
- `flows`: in the planned order. A reported flow carries `status` (`passed` or `failed`), `steps`, `device`, `durationMs` and `hasScreenshot`. A flow with a video carries `video` with `ended`, `endReason`, `evicted`, `notRecorded` and `bytes`, and `null` when it has none; `notRecorded` says why the runner could not keep one (`jpeg`, `no-keyframe`). A flow not reported yet is `running` (in progress now) or `pending` on a run in progress, and `not-run` on a finished one.
- `watchSessionId`: the session the runner holds now. Set only while the run is in progress; it goes in the [watch page](/automation/mcp-server#watch-the-device) address.
An unknown run gets `404`.
### `GET /api/v1/runs/:id/flows/:idx/screenshot`
The flow's screenshot as `image/png` or `image/jpeg`, or `404` when there is none.
## Flow run videos {#run-videos}
Each flow's video, uploaded by the runner as the flow runs. The runner opens a video (`open`), sends numbered chunks (`chunks`) and ends it (`end`); the dashboard reads the chunk list (`index`) and fetches the file, which also answers byte ranges. Writes follow the same rules as the run endpoints above, and the last flow's tail is still taken for 120 seconds after the runner's `finish`. Passed flows' videos are removed after the retention window, and when storage is full the oldest passed ones go first; both are [team settings](#settings).
### `POST /api/v1/runs/:id/flows/:idx/video/open`
Open a flow's video. The body is the video's metadata as JSON, up to 16 KB. `formatVersion` must be `1`; the rest is the runner's, stored as given.
**Response `201`** `{ "ok": true, "nextSeq": 0 }`. An open video answers `200` with the `nextSeq` to send next; an ended one gets `409`.
### `POST /api/v1/runs/:id/flows/:idx/video/chunks/:seq`
Send chunk `seq`, from 0 to 10000. The body is the chunk's bytes as they are, up to 8 MB.
```
Headers:
X-Start-Ms required The chunk's first record on the video's clock, in ms
X-Keyframe optional "1" when the chunk starts with a keyframe decoding can begin from
```
**Response `200`** `{ "ok": true, "nextSeq": n }`. A chunk already taken answers `200` with `"duplicate": true`. A video not opened gets `404`, an ended one `409`, and one no longer kept `410`. Over 8 MB gets `413` (`reason: "chunk-too-large"`); a flow's video past 100 MB gets `413` (`reason: "size-limit"`) and ends. Full storage gets `507` (`reason: "storage-full"`) and ends the video.
### `POST /api/v1/runs/:id/flows/:idx/video/end`
End the video. Only the first ending is recorded, so sending it again changes nothing.
```
Body (JSON):
lastSeq number required The last chunk sent, or -1 when none was
reason string required done | cancelled | size-limit | storage-full | runner-lost | truncated
```
**Response `200`** `{ "ok": true }`
### `GET /api/v1/runs/:id/flows/:idx/video/index`
The video's chunk list. Call it with the session cookie or a `view`-scope PAT.
```json
{
"meta": { "formatVersion": 1, "codec": "h264", "platform": "ios", "clockBase": 1760230395000, "flowStartMs": 1500 },
"bytes": 2483201,
"ended": true,
"endReason": "done",
"evicted": false,
"chunks": [{ "seq": 0, "offset": 0, "length": 412300, "startMs": 0, "keyframe": true }],
"gaps": []
}
```
`gaps` lists the chunks that never arrived. With no `end`, a video is `ended` once its run finished more than 120 seconds ago (`endReason: "truncated"`) or the relay closed the run because its runner disconnected (`runner-lost`).
### `GET /api/v1/runs/:id/flows/:idx/video`
The video file as `application/octet-stream`. A `Range` header gets part of it (`206`); a range outside the file gets `416`. A video still being recorded is `Cache-Control: no-store`; an ended one is `private` with an `ETag`. A video no longer kept gets `410`.
## Agents
### `GET /api/v1/agents`
Return the names of agents that have resource samples on record. Samples are kept for 30 days, so an agent that is not connected now can still appear.
**Response `200`**
```json
["mac-mini-office", "mac-mini-lab"]
```
### `GET /api/v1/agents/:name/resources`
Return CPU and RAM time-series data for a specific agent.
```
Query:
range 1h|6h|24h|7d optional (default: 1h)
```
**Response `200`**
```json
[
{ "cpu_percent": 44.2, "mem_percent": 61.0, "recorded_at": "2025-05-15T12:00:00Z" }
]
```
Data is sampled once per minute and retained for 30 days.
## Sessions
Find a `sessionId` in the MCP server's `list_devices` result. Both endpoints take the session cookie or a PAT with the `view` scope.
### `GET /api/v1/sessions/:sessionId/screenshot`
Return the session device's current screen as an image.
```
Query:
format png|jpeg optional (default: png)
```
**Response `200`**: an `image/png` or `image/jpeg` body
Returns `404` when the session does not exist, `409` when the device is shut down, `502` when the agent is offline or the capture fails, and `504` when there is no answer within 10 seconds.
### `GET /api/v1/sessions/:sessionId/ui-tree`
Return the UI elements on the session device's current screen.
**Response `200`**
```json
{
"elements": [
{
"role": "button",
"label": "Sign in",
"identifier": "login_button",
"frame": { "x": 0.1, "y": 0.8, "width": 0.8, "height": 0.06 },
"enabled": true
}
]
}
```
`frame` is a 0–1 fraction of the screen size. The error status codes match the screenshot endpoint, with a 15-second timeout.
## Relay
### `GET /api/v1/relay/host`
Return the relay address details the dashboard uses to build addresses for teammates. Requires sign-in.
**Response `200`**
```json
{
"lanHost": "192.168.0.10",
"port": 4000,
"publicBaseUrl": "https://tap.example.com",
"agentRelayUrl": "wss://tap.example.com"
}
```
A value that cannot be determined is `null`. `lanHost` is always `null` when the relay runs in a container.
## Logs
### `GET /api/v1/logs`
Return the relay's in-memory log buffer (last 500 lines). Only requests from the relay host are answered. Another machine, a tunnel, or a remote client behind a trusted proxy gets `403`, signed in or not. To read the logs from elsewhere, look at the relay host's own output (terminal, `journalctl`, `docker compose logs`).
```
Query:
lines number optional (an integer from 1 to 500, default: 100). A non-number means 100; a value out of range is clamped to the nearest end.
```
**Response `403`**
```json
{ "error": "Logs are only available on the relay host. Run `tapflow logs` there." }
```
**Response `200`**
```json
[
"[2025-05-15T12:00:00.000Z] ..."
]
```
## Related {#related}
- [Upload from CI](/operate/ci-distribution): uploading builds with a personal access token
- [Team, roles & tokens](/operate/team-and-roles): which role can call which endpoint, and how to issue tokens
- [CLI Reference](/reference/cli): the commands that run the relay and agents
---
# https://www.tapflow.dev/reference/cli
# CLI Reference
## Installation
::: code-group
```sh [npm]
npm install -g tapflow
```
```sh [yarn]
yarn global add tapflow
```
```sh [pnpm]
pnpm add -g tapflow
```
:::
To update:
```sh
npm update -g tapflow
```
## `tapflow doctor`
Diagnose environment issues. Omit the platform to check all, or pass `ios` / `android` to check one.
```sh
tapflow doctor
tapflow doctor ios
tapflow doctor android
```
Checks (a device/AVD only needs to *exist* — booting is on-demand via the relay):
- **Common**: Node.js version, and whether port 4000 is free. This check fails while a relay is running on port 4000 on this Mac.
- **iOS** (macOS only): Xcode, `xcrun simctl`, an available simulator, the network filter, the network hook, the network hook symbols, and Lean mode
- **Android**: Android SDK, adb, aapt (build-tools), AVD, and Lean mode
The network filter is reported as two checks, because they fail for different reasons: whether it is
**installed, approved and switched on**, and whether the versions on this Mac are the ones this
tapflow carries. Switched on is a third thing with no version of its own — the extension stays listed
as activated when the filter is turned off, so that state has every version correct and nothing
filtering. The second is not implied by the first — replacing an extension only finishes when the Mac
restarts, so the app on disk can be current while the old one is still doing the filtering.
**There are two versions, and the check names whichever is behind.** The app in `/Applications` and
the system extension inside it are versioned separately, because a tapflow release that changes only
the app has no reason to make macOS replace a running filter. The app is the binary the agent calls,
so it matters on its own: a stale one meets requests it does not understand. Both are
warnings rather than failures: a session works without the filter, and only iOS network control does
not. See [Network Control](/testing/network-control).
The network hook is the library tapflow injects to tell an app it is offline. It comes with tapflow,
so a missing one means the install is damaged and reinstalling restores it. It is reported separately
because its absence is otherwise silent: macOS ignores an injection path that does not exist without a
word, so the app launches unhooked and the network control goes on asking you to launch an app through
tapflow — while the app you launched is running in front of you.
Use `--json` for machine-readable output. Exits with code `1` if any check fails; a check that only warns does not count as a failure.
| Option | Description |
|--------|-------------|
| `[platform]` | `ios` or `android`; omit to check all |
| `--json` | Emit `{ ok, common, ios, android }` as JSON (no ANSI) |
See [Environment Setup](/operate/environment-setup) for the full workflow.
## `tapflow setup`
Install and configure the local environment so a platform is ready to run. Omit the platform to auto-detect, or pass `ios` / `android`.
```sh
tapflow setup
tapflow setup ios
tapflow setup android
```
Runs in one pass, asking for consent before each install (interactive terminals only; non-interactive runs print the command instead). On both platforms it installs Homebrew first when it is missing.
- **iOS**: opens the App Store for Xcode, accepts the license / runs first-launch (needs sudo), downloads a simulator runtime.
- **Android**: installs a JDK, builds a self-contained SDK at `~/Library/Android/sdk` (command-line tools, platform-tools, emulator, system image — no Android Studio GUI), and creates a set of AVDs across form factors.
Exits with code `0` when every step is ready and `1` whenever it prints `SETUP INCOMPLETE`. That is stricter than `doctor`, which passes a check that only warns: setup reports whether the work it was asked to do got done, not whether the Mac is usable.
On macOS, `setup ios` also installs the network filter that iOS network control needs — it asks
first, like every other install here. When the Mac has no approved extension yet, it also asks whether
to open the approval screen when macOS asks. Unless you decline that offer, an approval that takes
longer than two minutes still finishes the install: it waits for the switch. If you decline the install, or the Mac was set up before the filter shipped,
[`tapflow migrate net-filter`](#tapflow-migrate-net-filter) installs it on its own.
Installing it ends with a wait of up to thirty seconds for the filter to report itself running, so
that step can pause before it reports. When nothing reports, setup says so rather than showing the
step as done — see
[the same behaviour under `migrate net-filter`](#tapflow-migrate-net-filter).
setup only ensures a bootable device/AVD exists; the relay boots it on demand when a session opens. After it registers `ANDROID_HOME`/PATH, open a new terminal (or `exec $SHELL`) before running `tapflow doctor`.
| Option | Description |
|--------|-------------|
| `[platform]` | `ios` or `android`; omit to auto-detect |
See [Environment Setup](/operate/environment-setup) for the full workflow.
## `tapflow init`
Set this machine's tapflow up: `tapflow.config.json`, the `AGENTS.md` and `CLAUDE.md` a coding agent reads, and the credentials `.env` when you choose DNS auto-issue. Run it from anywhere — it writes to the install directory, `~/.tapflow` unless `TAPFLOW_HOME` or an install in the current directory says otherwise ([which install a command uses](/operate/configure#which-install-a-command-uses)). It creates the directory when it is missing.
Running it again keeps the configuration and refreshes the tapflow section of `AGENTS.md`, so an existing install can pick that up; pass `--force` to write a fresh configuration. `--tunnel` on an install that already has a configuration stops with an error instead, because keeping the configuration would ignore the flag.
If no tunnel flag is given and the terminal is interactive, a prompt guides you through tunnel selection. If you pick no tunnel, it also asks about streaming performance (HTTPS). On a machine with simulators or emulators it asks about Lean mode too, whichever tunnel you pick. The answers go to `tls` and `agent.lean` in the config. In a non-interactive environment with no `--tunnel` flag, a config file with no tunnel section is created.
```sh
tapflow init
```
| Option | Description |
|--------|-------------|
| `--tunnel ` | Tunnel provider: `tailscale` or `rathole` |
| `--force` | Overwrite existing `tapflow.config.json` |
Example (Tailscale):
```sh
tapflow init --tunnel tailscale
# ✓ CONFIG CREATED
# Install dir: /Users/you/.tapflow (default)
# tapflow.config.json created.
# AGENTS.md created for your coding agent.
# Tunnel: tailscale
# → Next: tapflow start
```
Generating config with no tunnel (defaults):
```sh
tapflow init
# ✓ CONFIG CREATED
# → Next: tapflow start
```
Setting the install up somewhere else:
```sh
TAPFLOW_HOME=/var/lib/tapflow tapflow init
```
## `tapflow admin init`
Create the first admin account on the relay via CLI. Use this as a fallback on a server where a browser is not available.
The relay must be running before executing this command. It prompts for an email and a password, so it needs an interactive terminal; without one (in CI, for example) it exits with code `1`. The relay only creates the first account for a request from its own machine (localhost), so pointing `--relay` at a remote relay gets a `403`. Run it on the machine where the relay runs.
```sh
tapflow admin init
```
| Option | Description |
|--------|-------------|
| `--relay ` | Relay URL (default: `relay.url` in config, or `http://localhost:4000`) |
Example:
```
? Admin email: admin@yourteam.com
? Password: ********
✓ Admin account created
→ Open http://localhost:4000 to sign in
```
Password must be at least 8 characters.
::: tip Web onboarding
On first launch, the dashboard automatically redirects to `/setup` where you can create the admin account in the browser — no CLI required. Use `tapflow admin init` only when a browser is not available.
:::
## `tapflow start`
Starts the relay and agent together on the same Mac. This is the command for running tapflow on a single Mac. When no platform is available, it starts the relay alone.
```sh
tapflow start
```
| Option | Description |
|--------|-------------|
| `--platform ` | Platform to start (default: auto-detect) |
| `--device ` | Limit which devices are exposed to the relay (default: all): iOS simulators by name or UDID, Android emulators by AVD name or device ID. The dashboard boots a device on demand. |
::: info For team deployments
If you are running the relay on a separate server, use `tapflow relay start` and `tapflow agent start` instead.
:::
## `tapflow relay start`
Start the relay server only. Used when deploying the relay to a server.
```sh
tapflow relay start
```
| Option | Default | Description |
|--------|---------|-------------|
| `--port ` | `local.port` (default `4000`) | Port to listen on |
| `--tunnel ` | — | `tailscale` or `rathole`. Stops with an error when `tapflow.config.json` has no `tunnel` section. The tunnel that starts is decided by the `tunnel` section whatever this value is, and a `tunnel` section starts it without this flag too. |
**Tailscale (recommended)**
```sh
tapflow relay start
```
`tapflow.config.json`:
```json
{
"tunnel": {
"provider": "tailscale"
}
}
```
tapflow reads the Tailscale MagicDNS hostname automatically. Set `"publicUrl"` to override the auto-detected URL.
**VPS + rathole**
Put `TAPFLOW_TUNNEL_TOKEN` in the data directory's `.env` (`~/.tapflow/data/.env` by default), then:
```sh
tapflow relay start
```
`tapflow.config.json`:
```json
{
"tunnel": {
"provider": "rathole",
"serverAddr": "your-vps.com:2333",
"publicUrl": "https://your-vps.com",
"ssh": {
"host": "your-vps.com",
"user": "ubuntu",
"keyPath": "~/.ssh/id_ed25519"
}
}
}
```
The `ssh` section lets tapflow connect to the VPS and manage the rathole server automatically — downloading, installing, and starting it on first run. If `ssh` is omitted, tapflow assumes the rathole server is already running on the VPS.
When the tunnel is ready, the public URL is printed in the banner. If the tunnel fails to connect, the relay continues to run — only the tunnel is unavailable.
Every `tunnel` key is listed under [Configuration](/reference/configuration#tunnel). See [External access](/operate/external-access) for full setup instructions.
## `tapflow agent start`
Start the agent only and connect it to a relay. Does not start a local relay.
```sh
tapflow agent start --relay ws://192.168.x.x:4000 --token tflw_pat_xxxxxxxx
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `relay.url` in config, or `ws://localhost:4000` | Relay WebSocket URL. Omit if `relay.url` is set in `tapflow.config.json`. |
| `--platform ` | auto-detect | Platform to start |
| `--device ` | all devices | Limit which devices are exposed to the relay: iOS simulators by name or UDID, Android emulators by AVD name or device ID |
| `--token ` | `TAPFLOW_AGENT_TOKEN` env | Token with the `agent` scope, required by remote relays. See [Agent Setup](/operate/agents#remote-relay-authentication). |
`--relay` must start with `ws://` or `wss://`. A Mac runs one agent per platform: when an agent for the same platform is already running, it prints `AGENT ALREADY RUNNING` and exits. When no platform is available, it exits with code `1`.
## `tapflow devices`
List available simulators and emulators.
```sh
tapflow devices
```
## `tapflow boot`
Boot a simulator or emulator by name or UDID. Searches iOS simulators first, then Android AVDs.
```sh
# iOS
tapflow boot "iPhone 16 Pro"
tapflow boot 822F00B0-D9CF-4B78-8EDD-6322974E4079
# Android (AVD name)
tapflow boot Pixel_8
```
Android AVDs start in the background. Run `tapflow devices` to check status.
## `tapflow reset`
Shut down all simulators and emulators.
```sh
tapflow reset
```
A confirmation prompt is shown (`y/N`). Enter `y` to proceed.
## `tapflow status`
Show connected agents, devices, and active sessions.
```sh
tapflow status
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `relay.url` in config, or `ws://localhost:4000` | Relay WebSocket URL. Omit if `relay.url` is set in `tapflow.config.json`. |
::: info How it connects
`tapflow status` connects to the relay over WebSocket to fetch information. Times out after 5 seconds if there is no response. Use the `--relay` option when connecting to a remote relay.
:::
Example output:
```
● mac-mini-office (iOS)
◉ iPhone 16 Pro ← qa@company.com
○ iPhone 15
1 agent(s) · 2 device(s) · 1 active session(s)
```
## `tapflow logs`
Show the recent log entries the relay keeps in memory (last 100 lines by default). Few events are recorded in this buffer. The relay's full log goes to the terminal it runs in.
The relay shows these logs only to the relay host, so run this command there. Pointed at a remote relay from another machine, it gets `403` and the CLI tells you what to run on the relay host instead. Under Docker, a CLI outside the container counts as remote too; use `docker compose logs`.
```sh
tapflow logs
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `http://localhost:` (4000 by default) | URL of the relay on this machine. Set it when the relay runs on another port. `relay.url` is not read. |
| `--lines ` | `100` | Number of log lines to show (max 500) |
## `tapflow flow run`
Replay saved flow files with no LLM involved. See the [Flow Reference](/automation/flows) for how to write them.
```sh
tapflow flow run .tapflow/flows/login-smoke.yaml
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `ws://localhost:4000` | Relay WebSocket URL. Does not read `relay.url`. |
| `--token ` | `TAPFLOW_TOKEN` env, then the [token file](/automation/mcp-server#token-file) | Personal access token (PAT) for a remote relay. Needs an API-type token (`view, builds:write`). |
| `--session ` | — | Target session ID (from the MCP server's `list_devices`) |
| `--device ` | — | Target device by name. Boots it when it is shut down. |
| `--build ` | — | Build under test. Installed before the run and launched by the `launchApp` step. |
| `--no-install` | — | Run without installing `--build` |
| `--junit ` | — | Where to write a JUnit XML report |
| `--artifacts ` | `.tapflow/artifacts` | Directory for failure screenshots |
| `--timeout ` | `10` | Default wait per selector (seconds) |
| `--no-record` | — | Do not record this run on the relay |
With neither `--session` nor `--device`, it uses the booted device when exactly one is booted. With none booted, or more than one, it stops with an environment error.
| Exit code | Meaning |
|-----------|---------|
| `0` | Every flow passed |
| `1` | At least one flow failed on a product problem |
| `2` | Environment or config error, or every failed flow failed on an environment problem |
| `130` | Cancelled by `SIGINT` |
| `143` | Cancelled by `SIGTERM` |
With a token, the run is recorded on the relay. Recording never changes the exit code. See [Run records](/automation/flows#run-records).
## `tapflow token set`
Store a token in the [token file](/automation/mcp-server#token-file) the MCP server and `tapflow flow run` read. The relay is asked first, and a token it does not accept is not stored.
```sh
tapflow token set --relay wss://tapflow.example.com
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `TAPFLOW_RELAY_URL` env, then `ws://localhost:4000` | The relay the token is for. Use the address the MCP server and `flow run` connect to. Does not read `relay.url`. |
- In a terminal it asks for the token with a hidden prompt. With no terminal, as in CI, it reads the first line of stdin (`echo "$TOKEN" | tapflow token set`). It never takes the token as an argument.
- It checks that the token has the `view` scope. Recording runs also needs `builds:write`, which is not checked.
- A token for the same relay is replaced, and other relays' tokens are kept. The file is created readable by you only (`600`).
## `tapflow token status`
List the relays the token file has a token for, and check whether each token still works. The relay the MCP server connects to by default (`TAPFLOW_RELAY_URL`, else `ws://localhost:4000`) is marked `← current`. Tokens are never printed. `flow run` does not read `TAPFLOW_RELAY_URL` and follows `--relay` only.
```sh
tapflow token status
```
| Option | Default | Description |
|--------|---------|-------------|
| `--relay ` | `TAPFLOW_RELAY_URL` env, then `ws://localhost:4000` | The relay to mark as current |
## `tapflow migrate`
Run every migration this install still needs, after an upgrade.
```sh
tapflow migrate
```
It checks each migration below and lists the ones that apply:
- `data-dir`, when the install directory has a `.tapflow-data/` with data in it.
- `net-filter`, on macOS, when the iOS network filter is installed but older than this version of tapflow, or installed and not filtering. A Mac that never installed the filter is left alone, because the filter is optional.
In a terminal it shows the list and asks once. Without one (CI, a provisioning script) it prints the list and runs it without asking, the same as each subcommand does on its own. The `net-filter` step can still stop to ask about the macOS approval.
Migrations run in order, and the first one that fails stops the rest with exit code 1. Nothing to do exits 0. When both `.tapflow-data/` and `.tapflow/data/` exist, `data-dir` is reported and skipped, because only you can tell which one holds your data. `net-filter` is reported and skipped the same way when the filter app was deleted but its extension is still running; `tapflow doctor ios` explains that state.
`--ignore-running-devices` is not accepted here. Run `tapflow migrate net-filter --ignore-running-devices` for that.
## `tapflow migrate data-dir`
Move a legacy `.tapflow-data/` into the unified `.tapflow/data/` layout. Run this once after upgrading; it is idempotent and safe to re-run.
Stop the relay first. If something is listening on the relay's port (`local.port`, or `TAPFLOW_PORT`), the command refuses and moves nothing: a relay running during the move would put later uploads back into `.tapflow-data/`. A relay started on another port with `tapflow relay start --port` is not detected, so stop that one yourself.
```sh
tapflow migrate data-dir
```
What it does:
- Atomically renames `.tapflow-data/` → `.tapflow/data/` — a single filesystem rename, no copy and no half-moved state.
- Repoints `local.dataDir` in `tapflow.config.json` when it still pins the old default `.tapflow-data`. A custom path is left untouched.
- Adds `.tapflow/data/` and `.tapflow/artifacts/` to `.gitignore` so the moved secrets stay out of git.
Existing installs keep working without running this — a pinned `local.dataDir` is honored, and a config-less default install keeps reading `.tapflow-data/`. If the two paths are on different filesystems, or both already exist, the command stops and prints the manual step instead of guessing. The config is rewritten before the move and put back if the move fails; if it cannot be put back, the command prints the value to set it back to.
## `tapflow migrate net-filter`
Install the iOS network filter on a Mac that was set up before tapflow shipped it. macOS only.
```sh
tapflow migrate net-filter
```
`tapflow setup ios` also installs the filter, but setup is the command you run to prepare a new
machine. A Mac that is already configured has no reason to run it again, so the extension would
arrive in `node_modules` and never reach the Mac. This is the command for that — and for a `setup`
run where you declined the filter.
It copies the signed extension that came with `@tapflowio/ios-agent` into `/Applications` and asks
macOS to activate it. Approving it is a step you take at that Mac, in **System Settings → General →
Login Items & Extensions → Network Extensions**; macOS offers no command-line equivalent, so the
command tells you when it is waiting on you.
**It offers the approval screen before installing, and opens it during the wait.** When no tapflow
extension is `[activated enabled]`, macOS is going to ask, so in an interactive terminal the command
asks first whether to open the approval screen when it does. The question also says that switching
the filter on can drop connections this Mac already has open, SSH sessions included. Say yes and the
screen opens as soon as `systemextensionsctl list` shows the request `waiting for user`. macOS does
not show its own prompt again for a request already waiting, so on a rerun this is the only pointer
there is. The device check runs after this question. Pressing Ctrl-C or Esc at it stops the command
with nothing installed.
**A late approval still finishes in the same run.** The install waits up to two minutes for approval
and ends there if the entry is switched on in time. Otherwise the command waits up to two more
minutes, and if the entry is switched on then, it switches the filter on and confirms it is running.
If it did not ask up front, because macOS was not expected to ask (replacing an approved extension,
for instance), it asks at this point. If macOS asks whether to allow tapflow to filter network
content when the filter goes on, allow it.
**It never says the screen opened.** The command has no way to know whether a window appeared, so it
shows the path alongside. If nothing appears, go there by that path.
**When the command switches the filter on itself, it checks for devices again first**, because
someone may have booted a simulator during the extra wait. If it finds one, it stops without
switching the filter on and says so if the filter is left off. With `--ignore-running-devices` it
does not check again. An approval within the first two minutes lets the extension switch the filter
on by itself, so on that path the only device check is the one when the install starts.
Outside an interactive terminal it does not ask; it counts as interactive only when stdin and
stdout are both terminals. There, and when you decline, it ends waiting for approval unless you
approve it yourself within the first two minutes, and a declined offer is not repeated. The same
happens when the entry is not switched on within the extra two minutes. It then tells you to approve
the extension and run the command again. That run switches the filter on.
It **refuses to replace a filter newer than the one it carries**. `/Applications` holds one copy for
the whole Mac while each install judges it by its own dependencies, so an older checkout would
otherwise downgrade the filter a newer agent depends on.
It **refuses while devices are in use.** Replacing the filter interrupts new connections on the Mac
while it happens, and the people affected are not necessarily the person at the keyboard. Booted
simulators, attached emulators and a relay serving on `:4000` all count. The command names what it
found and stops.
```sh
tapflow migrate net-filter --ignore-running-devices
```
That replaces it anyway. The flag belongs to `net-filter`; `tapflow migrate data-dir` rejects it
rather than ignoring it.
**The replace switches the filter off first, then back on.** A content filter sits in front of every
new connection on the Mac, not only the simulator's, and when it stops while it is still switched on
macOS blocks all of them rather than letting anything through unchecked. New connections fail
immediately with `No route to host`. Taking the filter out of the path first means that state never
happens.
It goes off **before the app is copied into `/Applications`**, not only before the extension is
activated, because both steps stop the filter: copying the app makes macOS restart the filter session
on its own timing. It goes off again after the copy, from the binary that was just copied, which is
the one that gates the activation.
If the command fails partway it says whether the filter was left off. The Mac's network works in that
state and iOS network control does not; running the command again turns it back on.
**It waits for the filter to report itself running before saying it worked.** macOS answers "not
refused" rather than "working" when an extension is installed — the configuration reaches the filter
afterwards, with nothing coming back — so the command watches for up to thirty seconds and leaves as
soon as a filter appears. `tapflow setup ios` does the same when it installs the filter.
When none appears the command says so and **exits non-zero**, because that state is the one where the
configuration is switched on and nothing is answering for it. Usually the filter is simply still
starting, and `tapflow doctor ios` will say so a moment later. If new connections on the Mac have
stopped working, see [Troubleshooting](/operate/network-extension#network-lost-on-replace) — the remedy is
to take the filter out of the path with `--off`.
Run `tapflow doctor ios` afterwards to confirm what the Mac ended up with.
## Related {#related}
- [Quick Start](/get-started/quick-start): the commands in the order a first install runs them
- [Configuration](/reference/configuration): the config file and environment variables the commands read
- [Troubleshooting](/troubleshooting): what to do when `tapflow doctor` or a command fails
---
# https://www.tapflow.dev/reference/configuration
# Configuration
The relay reads `tapflow.config.json` from this machine's install directory — `~/.tapflow` unless `TAPFLOW_HOME` or an install in the current directory says otherwise ([which install a command uses](/operate/configure#which-install-a-command-uses)). Generate it by running `tapflow init`, then restart the relay after any changes.
Paths inside the file are relative to the file itself, the way a `tsconfig.json` or a `litestream.yml` reads its own: `"dataDir": "data"` in `~/.tapflow/tapflow.config.json` means `~/.tapflow/data`, wherever you run the command from.
## Example
```json
{
"local": {
"port": 4000,
"dataDir": "data"
},
"relay": {
"url": "wss://your-relay-url"
},
"smtp": {
"host": "smtp.example.com",
"port": 587,
"secure": false,
"user": "relay@example.com",
"pass": "password"
},
"webhooks": [
{ "url": "https://ci.internal/hooks/tapflow", "secretEnv": "TAPFLOW_WEBHOOK_SECRET_CI" }
]
}
```
| Key | Description |
|-----|-------------|
| `local` | Settings for the relay server running on this machine. |
| `relay.url` | URL of the relay to connect to. Used by `tapflow agent start`, `tapflow admin init`, and `tapflow status` as the default — no `--relay` flag needed when this is set. `tapflow logs` does not read it: it asks the relay on the machine it runs on (`http://localhost:[local.port]`), so run it on the relay host. Leave empty for local mode (`ws://localhost:[local.port]`). Write it as `ws://` or `wss://`: `tapflow agent start` rejects any other scheme, and the other commands switch it to HTTP where they need to. |
| `tunnel` | The tunnel that `tapflow start` and `tapflow relay start` bring up alongside the relay. See the Tunnel section below. |
| `tls` | LAN HTTPS (secure context) settings, required for WebCodecs hardware decode. See the HTTPS section below. |
| `smtp` | SMTP settings for sending invitation and password reset emails. |
| `webhooks` | Outbound endpoints notified when a build's review status changes. Signing secrets are read from env vars named by `secretEnv`. See the Webhooks section below. |
| `agent.lean` | Lean mode for the iOS simulators and Android emulators this machine's agent boots. Read by the agent, not the relay. Default `false`. See the Lean mode section below. |
`smtp.from` defaults to `tapflow ` when `smtp.user` is set, and to `tapflow ` otherwise. Override it explicitly if you need a different sender address.
## Environment variable overrides
Environment variables always take precedence over the config file — useful for server deployments and CI.
Secrets can also live in the data directory's `.env` file. The relay loads it first thing on start, so any variable below can come from there instead of the shell. Precedence is **shell env > `.env` > config file**. See [Configuring tapflow](/operate/configure) for the file format and the one exception (`TAPFLOW_DATA_DIR`).
| Variable | Config key | Default | Description |
|----------|------------|---------|-------------|
| `TAPFLOW_PORT` | `local.port` | `4000` | Server port |
| `TAPFLOW_TUNNEL_PORT` | `local.tunnelPort` | `4001` when a tunnel is configured, otherwise off | Loopback-only port for tunnel clients such as rathole, `tailscale serve` and `cloudflared`. A connection on this port counts as remote even though it comes from the relay's own machine, so it signs in or presents a token. `tapflow start` and `tapflow relay start` open it whenever a `tunnel` is configured. Everywhere else, including the Docker image, it opens only when this variable or `local.tunnelPort` names a port — set it there for any tunnel or proxy that reaches the relay from the relay's own network namespace. If the relay itself uses `4001`, the default moves to `4002`. Inside a container, only something that shares the relay's network namespace can reach it. |
| `JWT_SECRET` | — | *(auto-generated)* | JWT signing key (env only). If unset, a strong per-install secret is generated on first boot and persisted to the data directory. A value you set must be at least 32 characters; a shorter one stops the relay from starting. |
| `TAPFLOW_HOME` | — | `~/.tapflow` | The install directory: where `tapflow.config.json` and, by default, the data directory live. Every command reads it. A relative value is taken from the current directory; an empty one counts as unset. A command that runs or reaches the relay stops when it names a directory that does not exist — `tapflow init` creates it instead. |
| `TAPFLOW_DATA_DIR` | `local.dataDir` | `/data` | DB and uploads directory. Relative to the current directory here, and to the config file in `local.dataDir`. An install that already holds `.tapflow/data` or `.tapflow-data` keeps using it. |
| `TAPFLOW_RELAY_URL` | `relay.url` | *(empty)* | Relay URL used as default by CLI commands |
| `TAPFLOW_AGENT_TOKEN` | — | *(empty)* | Token with the `agent` scope for remote relay authentication. The `--token` flag takes precedence. See [Agent Setup](/operate/agents#remote-relay-authentication). |
| `TAPFLOW_TOKEN` | — | *(empty)* | Personal access token (PAT) that `tapflow flow run` and the MCP server use to reach a remote relay. Use an API-type token from the dashboard (`view, builds:write`). For `flow run`, the `--token` flag takes precedence. When empty, both read the [token file](/automation/mcp-server#token-file). |
| `TAPFLOW_TOKEN_FILE` | — | `~/.config/tapflow/credentials.json` | Path of the token file `tapflow flow run` and the MCP server read. With `XDG_CONFIG_HOME` set, the default is `tapflow/credentials.json` under it. |
| `TAPFLOW_TUNNEL_TOKEN` | — | *(empty)* | Shared secret that authenticates the rathole tunnel. Required when `tunnel.provider` is `rathole`. |
| `TAPFLOW_LEAN` | `agent.lean` | `off` | `on` or `off`. Any other value is ignored with a warning, and the config file's value is used. |
| `TAPFLOW_TRUSTED_PROXIES` | — | *(empty)* | Comma-separated IPs of trusted reverse proxies (e.g. `127.0.0.1,::1`). Set this when the relay runs behind a same-host reverse proxy so it reads the real client IP from `X-Forwarded-For` instead of the proxy's address. Empty disables forwarded-header parsing. |
| `TAPFLOW_BUILD_TTL_DAYS` | — | `7` | Days a build is kept after its deletion is scheduled before the files and record are purged. Scheduling is a manual action — marking a build **Done** no longer deletes it. Set to a small value (e.g. `0.001`) to verify cleanup quickly in local testing. |
| `TAPFLOW_MAX_BUILD_BYTES` | — | `524288000` (500 MB) | Maximum build upload size (bytes). |
| `TAPFLOW_MAX_UNPACKED_BYTES` | — | 4× the upload limit | Maximum unpacked size of an iOS `.tar.gz` build (bytes). |
| `TAPFLOW_MAX_COMMENT_BYTES` | — | `5242880` (5 MB) | Maximum size of a comment's image attachment (bytes). |
| `IDLE_TIMEOUT_MS` | — | `300000` (5 min) | Milliseconds a session waits after its browser leaves before it ends. |
| `TAPFLOW_RESOURCE_THRESHOLD_PERCENT` | — | `80` | When CPU or memory use on the agent's Mac is above this percentage, new session joins are refused. |
| `TAPFLOW_WS_BACKPRESSURE_BYTES` | — | `1048576` (1 MB) | Binary frame drop threshold per browser socket. Frames are silently dropped when the socket buffer exceeds this value. |
| `TAPFLOW_AGENT_GRACE_MS` | — | `15000` (15 s) | Milliseconds a session stays alive after its agent's connection drops, waiting for that agent to come back. An agent registers about a second after its process starts, so the default covers a restart several times over; the open tab says it is waiting rather than showing a frame that has stopped updating, and the device is not offered to anyone else until the window closes. `0` disables the hold — the session ends the moment the agent's socket does, as it did before this existed. A blank, non-numeric or negative value falls back to the default and logs a warning at startup. |
| `TAPFLOW_CLOUDFLARE_TOKEN` | — | *(empty)* | Cloudflare API token for DNS-01 issuance when `tls.dnsProvider` is `cloudflare`. |
| `TAPFLOW_VERCEL_TOKEN` | — | *(empty)* | Vercel API token for DNS-01 issuance when `tls.dnsProvider` is `vercel`. |
| `TAPFLOW_VERCEL_TEAM_ID` | — | *(empty)* | Vercel team ID, required when the domain belongs to a team scope. |
| `TAPFLOW_ACME_EMAIL` | — | *(empty)* | Optional contact email for the Let's Encrypt account. |
| `TAPFLOW_ACME_STAGING` | — | *(empty)* | `1` issues from the Let's Encrypt staging environment. For testing; browsers do not trust the certificate it produces. |
| `TAPFLOW_ADMIN_EMAIL` | — | *(empty)* | Email for the first Admin account, created while the relay boots. Set it **together with** `TAPFLOW_ADMIN_PASSWORD`. Does nothing on an install that already has an owner. |
| `TAPFLOW_ADMIN_PASSWORD` | — | *(empty)* | Password for that account, at least 8 characters. |
| `SMTP_HOST` | `smtp.host` | `` | SMTP host |
| `SMTP_PORT` | `smtp.port` | `587` | SMTP port |
| `SMTP_SECURE` | `smtp.secure` | `false` | Enable TLS (set to string `"true"`) |
| `SMTP_USER` | `smtp.user` | `` | SMTP username |
| `SMTP_PASS` | `smtp.pass` | `` | SMTP password |
| `SMTP_FROM` | `smtp.from` | `tapflow ` (`tapflow ` without `smtp.user`) | Sender address |
| `LOG_LEVEL` | — | `info` | Log level: `debug`, `info`, `warn` or `error`. Read by both the relay and the agents. |
::: tip JWT_SECRET is optional
If `JWT_SECRET` is not set, the relay generates a strong per-install secret on first boot and stores it in the data directory (`jwt-secret`, owner-only). Set `JWT_SECRET` explicitly only when you need a fixed key — for example, to share one secret across multiple relay instances:
```sh
openssl rand -hex 32
```
Put the value in the data directory's `.env` (`~/.tapflow/data/.env` by default) or inject it as a shell environment variable. A value shorter than 32 characters is rejected.
:::
::: warning Point same-host proxies and tunnels at the tunnel port
The relay does not ask connections that reach it over loopback to sign in. A reverse proxy (nginx, Caddy), `cloudflared` or `tailscale serve` does exactly that when it runs beside the relay: on the same machine for a native install, or in the relay's own network namespace for a container. Pointed at the relay port, it makes **every client it forwards look local**. Point it at the tunnel port instead (`127.0.0.1:4001`, see `TAPFLOW_TUNNEL_PORT`). Every connection there counts as remote. A proxy on another host is remote already and needs no change, and that includes one reaching a container over Docker's bridge.
To log and rate-limit by the real client address, also set `TAPFLOW_TRUSTED_PROXIES` to the proxy's address (e.g. `127.0.0.1,::1`) and have the proxy forward `X-Forwarded-For`. A proxy that stays on the relay port needs this setting for its clients to count as remote. The header is required. A request from a listed address without it counts as coming from that address, so a proxy on the same machine that omits it makes its clients local again. The relay warns when a listed proxy sends `X-Real-IP` or `X-Forwarded-Proto` but no `X-Forwarded-For`. A tunnel that forwards raw TCP, like rathole, adds no header, so only the tunnel port helps there.
For proxied or tunneled deployments, also set a public URL (`tunnel.publicUrl` or `relay.url`). Otherwise the CORS/CSRF allowlist is loopback-only and the dashboard's cross-origin requests can be blocked.
:::
## Create the first Admin account in a Docker container (`TAPFLOW_ADMIN_EMAIL`) {#create-the-first-admin-account-in-a-docker-container-tapflow-admin-email}
Set both variables and the relay creates the first Admin account while it starts — the path for a Docker install, where neither the browser onboarding nor `tapflow admin init` can reach.
Normally you create that first account through the `/setup` page in a browser, and `tapflow admin init` stands in for it on a server with no browser. A container closes both doors: `/setup` only answers a request from loopback — the check that stops a stranger claiming a public instance first — and a container reaches the relay through its bridge gateway, while the relay-only image carries no CLI.
How it behaves:
- **It does nothing on an install that already has an owner.** Your account is never replaced, restarts do not repeat it, and none of the three checks below run.
- On an install with no owner, set both variables together. One without the other stops the relay starting.
- The password must be at least 8 characters. A shorter one also stops it starting.
- If the account you asked for could not be created, the relay does not start. An ownerless relay is claimable by anything that reaches loopback, so stopping is safer than serving.
Leave both unset and nothing changes.
There are two places to keep the values and **they do not combine.** Compose looks for what it interpolates in your shell or in the `.env` beside your compose file. The relay reads `.tapflow/data/.env` inside the volume. Different files.
### In your compose file
```yaml
services:
relay:
image: tapflow/tapflow:latest
environment:
- TAPFLOW_ADMIN_EMAIL=admin@yourteam.com
- TAPFLOW_ADMIN_PASSWORD=${TAPFLOW_ADMIN_PASSWORD:?set this before starting}
```
`${...:?}` makes Compose refuse to start when the value is missing — a literal here would be copied unchanged and become a known password. Supply it as a shell environment variable, or in the `.env` next to your compose file.
### In the relay's own `.env`
Leave both lines out of `environment:` and write them inside the volume you already mount. That keeps the password out of your compose file and your shell history.
```ini
# Paste your own password after the =. Left empty, the relay does not start.
TAPFLOW_ADMIN_EMAIL=admin@yourteam.com
TAPFLOW_ADMIN_PASSWORD=
```
Narrow the permissions on a file you create yourself.
```sh
chmod 600 .tapflow/data/.env
```
`tapflow init` creates that file with mode 0600, but the relay-only image has no CLI — so a container operator writes it under their own umask. The relay checks the mode at startup and warns when other users can read it.
```text
.tapflow/data/.env is readable by other users (mode 644). Run: chmod 600 .tapflow/data/.env
```
It is a warning rather than a refusal.
## Streaming tuning (agent)
These variables are set on the **agent** process (`tapflow agent start` / `tapflow start`), not the relay, and tune the video stream's LAN bandwidth ↔ fidelity trade-off. Diagnostic flags for *measuring* the stream (`TAPFLOW_STREAM_METRICS`, the `?perf=1` panel) are a contributor tool — see [measurement.md](https://github.com/jo-duchan/tapflow/blob/main/contributing/measurement.md).
| Variable | Default | Description |
|----------|---------|-------------|
| `TAPFLOW_IOS_CODEC` | `h264` | iOS stream codec — `h264` (default) or `jpeg`. H.264 also needs browser support; unsupported browsers fall back to JPEG automatically. |
| `TAPFLOW_IOS_H264_BITRATE` | `8000000` | iOS H.264 target bitrate (bits/s, soft cap). Lower = fewer LAN drops, more motion blockiness. |
| `TAPFLOW_JPEG_QUALITY` | `0.8` | iOS JPEG quality (0–1), JPEG path only. Lower = fewer drops, more artifacts. |
| `TAPFLOW_MAX_SIZE` | *(per connection)* | Downscale cap for the longest side (px), both platforms. Lower = less bandwidth and viewer decode load, lower fidelity. When unset, the viewer's connection decides: native on localhost and LAN HTTPS, `TAPFLOW_MAX_SIZE_LAN` on LAN HTTP, `TAPFLOW_MAX_SIZE_EXTERNAL` on an external connection. `0` means native on every connection. |
| `TAPFLOW_MAX_SIZE_LAN` | `1280` | Cap (px) for LAN HTTP connections when `TAPFLOW_MAX_SIZE` is unset. |
| `TAPFLOW_MAX_SIZE_EXTERNAL` | `1000` | Cap (px) for external connections when `TAPFLOW_MAX_SIZE` is unset. |
| `TAPFLOW_IOS_MAX_SIZE` / `TAPFLOW_ANDROID_MAX_SIZE` | *(per connection)* | Per-platform override of `TAPFLOW_MAX_SIZE`. |
| `TAPFLOW_ANDROID_FPS` | `30` | Android emulator capture frame rate (gRPC path). |
| `TAPFLOW_ANDROID_BACKEND` | *(auto)* | Force the Android backend — `grpc` or `scrcpy`. Auto-selected by device type when unset. |
| `TAPFLOW_ANDROID_GRPC_PORT` | `8554` | First port tried when picking a gRPC port for an emulator tapflow boots. The first free port from here, stepping by 2, is used. |
| `TAPFLOW_AUDIO` | *(on)* | `off` turns off device audio streaming. See [Audio](/testing/audio). |
| `TAPFLOW_ALLOW_DISPLAY_SLEEP` | *(empty)* | Any value lets the host display sleep during a session. System sleep is still prevented. See [Agent Setup](/operate/agents#host-display-and-sleep). |
## Lean mode (agent)
With `agent.lean` set to `true`, the agent turns off a fixed list of background services on every iOS simulator it boots, and a few bundled apps on Android emulators (see below). Measured on iOS 27, a simulator then uses about a quarter less memory, roughly 0.5 GB, so a Mac holds more simulators before it starts swapping.
```json
{ "agent": { "lean": true } }
```
**What turns off:** Siri and Apple Intelligence background work, iCloud Keychain and backup, the Health app, fitness and HomeKit, photo analysis, Family Sharing and Screen Time, News, Maps sync and Tips, iMessage and FaceTime, AirDrop, Continuity, CarPlay, Watch and Find My, Safari bookmark sync, and telemetry.
**What stays on:** the services apps commonly rely on and what a tester sees on screen. That covers the wallpaper and widgets, dictation, speech and keyboard suggestions, Sign in with Apple, CloudKit and iCloud Drive, StoreKit, push and Wallet, HealthKit, the photo picker, Contacts and Calendar, Spotlight and Settings search, universal links, WeatherKit, MapKit, Game Center and CallKit. The list is fixed rather than worked out from your app, so if an app under test needs a service from the list above, turn Lean mode off. tapflow's own features, including streaming, input, the UI tree, the clipboard, audio, installs, deep links and the network control, were checked on a lean simulator.
It only applies while tapflow runs the simulator:
- The agent writes the setting just before it boots a simulator that is shut down, and removes it when it shuts the simulator down. Booting that simulator later from Xcode or Simulator.app starts it with every service running.
- A simulator that is already running when a session asks for it is used as it is. Lean mode applies from its next boot through tapflow.
- If the agent stops without shutting a simulator down, that simulator stays lean while it keeps running, whoever opens it. Once it is shut down, the next agent to connect removes the setting.
- `tapflow boot` starts a simulator without going through the agent, so it boots it as it is.
It needs an iOS 18.5 or later runtime; other runtimes, tvOS and watchOS simulators are left alone. `tapflow doctor ios` shows whether Lean mode is on and how many simulators are lean right now.
### Android emulators
On Android the agent disables four bundled Google apps: the Google app, YouTube, YouTube Music and Digital Wellbeing. They start on their own at boot. Measured on an API 34 emulator, the guest then takes about 350 MB less of the Mac's memory, just under a fifth.
The emulator gives memory back to the Mac only when it exits, so the apps have to be disabled before a boot to save anything. That is why this works differently from iOS: the apps stay disabled for as long as Lean mode is on, and the saving starts from an emulator's second boot through tapflow.
- The home screen loses the Google search bar, and the assistant is gone. An app that sends `ACTION_WEB_SEARCH` finds nothing to handle it. Voice input keeps working, because other apps provide it.
- Photos, Messages, Gmail and Maps stay on, because apps open images, texts, mail and maps through them.
- An emulator that is already running when a session asks for it is not made lean until its next boot through tapflow.
- The apps are disabled in the emulator itself, so they stay disabled when you open it from Android Studio. While Lean mode is on, an app you re-enable by hand is disabled again the next time tapflow boots the emulator.
- When Lean mode is turned off, the apps come back the next time tapflow boots that emulator. To restore them without tapflow, run `adb shell pm enable ` for each one, then `adb shell rm /data/local/tmp/tapflow-lean.json`.
- `tapflow doctor android` shows whether Lean mode is on. Which emulators are lean is recorded inside each emulator, so doctor cannot count them.
In a setup with several Macs, each Mac's `tapflow.config.json` decides for the agent on that Mac.
## Tunnel
With `tunnel` set, `tapflow start` and `tapflow relay start` bring up a tunnel alongside the relay. The supported `provider` values are `tailscale` and `rathole`. Example configs are under [`tapflow relay start`](/reference/cli#tapflow-relay-start), and the full setup is in [External access](/operate/external-access).
| Key | Description |
|-----|-------------|
| `tunnel.provider` | `tailscale` or `rathole` (required) |
| `tunnel.publicUrl` | Public URL your team opens. Required for rathole. For Tailscale, the MagicDNS hostname is used when it is omitted. |
| `tunnel.serverAddr` | rathole server address (`host:port`). rathole only, required. |
| `tunnel.ssh.host` / `tunnel.ssh.user` | Host and user for managing the rathole server over SSH. Without `ssh`, tapflow assumes the server is already running. |
| `tunnel.ssh.keyPath` | Path to the SSH private key (optional). |
rathole also needs the `TAPFLOW_TUNNEL_TOKEN` environment variable.
## HTTPS (secure context) {#https-secure-context}
Hardware-accelerated video decode (WebCodecs) only runs in a secure context (HTTPS). Over HTTP the dashboard falls back to software decode, so to give teammates on the LAN a smoother stream, terminate the relay over HTTPS. With `tls` set, the relay terminates HTTPS and WSS on the same port.
There are two issuance modes.
### Auto-issue with your own DNS account (`byo-api-token`)
With your own domain and a DNS provider API token, the relay auto-issues and renews a Let's Encrypt certificate over DNS-01.
```json
{
"local": { "port": 4000 },
"tls": {
"mode": "byo-api-token",
"domain": "tap.yourcompany.com",
"dnsProvider": "cloudflare"
}
}
```
| Key | Description |
|-----|-------------|
| `tls.mode` | `byo-api-token` (auto-issue via Let's Encrypt DNS-01) or `import-cert` (your own files). |
| `tls.domain` | Domain the certificate is issued for. Teammates open `https://[domain]:[port]`. |
| `tls.dnsProvider` | `cloudflare` or `vercel`. The matching API token is read from the environment. |
| `tls.publishAddress` | Auto-publish the domain's A record to this machine's LAN IP. Default `true`; set `false` to manage DNS yourself. |
| `tls.address` | IP to use instead of the auto-detected LAN IP, for multi-NIC or VPN overrides. |
API tokens go in the `.env` file that `tapflow init` scaffolds in the data directory (`~/.tapflow/data/.env` by default), not in the config file. Cloudflare uses `TAPFLOW_CLOUDFLARE_TOKEN` and Vercel uses `TAPFLOW_VERCEL_TOKEN`, plus `TAPFLOW_VERCEL_TEAM_ID` for a team domain. When the install directory is inside a git repository, `tapflow init` adds the data directory to `.gitignore` so the file is not committed. If you point the data directory somewhere else, make sure that path is ignored too. A value set directly in the environment takes precedence over the file. See [Configuring tapflow](/operate/configure) for how the file is scaffolded and read.
When `publishAddress` is on, the relay publishes its LAN IP to the domain's A record on boot and refreshes it periodically, so teammates just open the domain without touching DNS.
### Bring your own certificate (`import-cert`)
To use an internal PKI or a wildcard certificate you already hold, point to the files. You manage renewal yourself.
```json
{
"tls": {
"mode": "import-cert",
"certPath": "/path/to/fullchain.pem",
"keyPath": "/path/to/privkey.pem"
}
}
```
| Key | Description |
|-----|-------------|
| `tls.certPath` | Path to the fullchain certificate PEM. |
| `tls.keyPath` | Path to the private key PEM. |
At startup, tapflow advertises the first concrete DNS SAN other than `localhost`; when the SAN extension is absent, it uses a concrete subject CN. If DNS SANs are present but none is usable — for example, a wildcard-only certificate — it advertises `localhost` and prints a warning. IP-only SANs and malformed certificates also fall back to `localhost`, without that DNS-SAN warning. The advertised name must resolve to the relay's LAN address for teammates to use it.
::: tip Access and known limits
- The certificate is bound to the domain, so open `https://[domain]:[port]`. Connecting via `localhost` or an IP raises a name-mismatch warning.
- Some routers block responses where a public domain points to a private IP (DNS rebinding). Add a router exception, or map the domain to the LAN IP via local DNS.
- On networks with WiFi client isolation, device-to-device traffic is blocked and LAN access is impossible. Use a normal home or office LAN.
- A staging certificate (`TAPFLOW_ACME_STAGING=1`) is untrusted, so browsers warn. Right after switching the same domain from staging to production, the browser may cache the old certificate error — re-check in a private window or after clearing history.
:::
## Data directory
The install directory is laid out as follows. `tapflow init` writes `tapflow.config.json`, `AGENTS.md` and `CLAUDE.md`, plus `data/.env` when you choose HTTPS with a DNS API token; the relay creates the rest of `data/` as it runs.
```text
~/.tapflow/
tapflow.config.json ← relay configuration (run tapflow init to generate)
AGENTS.md ← tapflow section for coding agents
CLAUDE.md ← @AGENTS.md
data/ ← relay runtime state
tapflow.db ← SQLite database
jwt-secret ← per-install signing key
.env ← credentials, when DNS auto-issue is used
uploads/
builds/ ← .app.zip, .tar.gz and .apk files
avatars/
comments/
team/ ← team logo
recordings/ ← session recordings (deleted after 72h)
```
Flow files are not part of the install: keep them in your app repository under `.tapflow/flows/`, where failure screenshots land in `.tapflow/artifacts/`.
To change the data directory location, set `TAPFLOW_DATA_DIR` or `local.dataDir`. Back up the data directory to preserve all data.
Upgrading from a version that kept its data in the directory you started the relay from? Nothing moves. That directory still counts as the install while it holds `tapflow.config.json`, `.tapflow/data` or `.tapflow-data`, so running the relay there uses exactly what it used before — and `local.dataDir`, including the `.tapflow/data` older `init` and `tapflow migrate data-dir` write, resolves against the config file beside it. To adopt the unified layout, stop the relay and run `tapflow migrate data-dir` once: it atomically renames `.tapflow-data/` → `.tapflow/data/` (no copy, no data loss), repoints `local.dataDir` when it pinned the old default, and updates `.gitignore`.
## SMTP
Without SMTP, invitation emails and password reset emails will not be sent. In that case, Admins copy the invite link or the password reset link from the dialog and share it directly.
To send invitation emails, configure `smtp.host`, `smtp.user`, and `smtp.pass`.
## Webhooks
tapflow POSTs to registered URLs when a build's review status changes to `Done` or `Rejected`. Declare endpoints in the `webhooks` array; the REST API can register more at runtime. The full payload, signature verification, and firing rules are in [Webhooks](/operate/webhooks).
| Key | Description |
|-----|-------------|
| `webhooks[].url` | Destination that receives the POST (required). |
| `webhooks[].secretEnv` | Name of the env var holding the HMAC signing secret. Secrets never go in config.json. |
| `webhooks[].enabled` | Whether the endpoint is active. Defaults to `true`. |
Changes to `webhooks` take effect after a relay restart.
## Related {#related}
- [Configure tapflow](/operate/configure): setting up a first install step by step
- [External access](/operate/external-access): tunnels and `relay.url` for teammates outside the LAN
- [CLI Reference](/reference/cli): the commands that read this file
---
# https://www.tapflow.dev/reference/keyboard-shortcuts
# Keyboard shortcuts
The dashboard's shortcuts, by where they work. Shortcuts use ⌘ (Command); only the clipboard and the sidebar also accept Ctrl.
## QA Session {#qa-session}
These do not fire while the cursor is in one of the dashboard's text fields.
| Shortcut | Action | Platform |
|---|---|---|
| ⌘ K | Open the [deep link](/testing/deep-links) box | iOS, Android |
| ⌘ S | Take a [screenshot](/testing/screenshots-and-recordings#screenshots) | iOS, Android |
| ⌘ ⇧ Y | Start or stop a [recording](/testing/screenshots-and-recordings#recordings) | iOS, Android |
| ⌘ ⇧ O | [Rotate](/testing/device-controls#rotate) | iOS, Android |
| ⌘ ⇧ U | Press the [Home button](/testing/device-controls#device-buttons) | iOS |
| ⌘ ⇧ K | Show or hide the [software keyboard](/testing/device-controls#software-keyboard) | iOS |
| ⌥ + drag | [Pinch](/testing/device-controls#touch-and-gestures) | iOS, Android |
| ⌘/Ctrl C·X·V | [Clipboard](/testing/device-controls#clipboard) copy, cut and paste between the device and your computer | iOS, Android |
## Typing on the device {#typing-on-the-device}
Click the device's screen, and every other key you press goes to the device, with ⇧, Ctrl and ⌘ held as modifiers. The **Focus** indicator in the info card turns green while this is on. Clicking anywhere outside the device ends it.
## Dashboard {#dashboard}
| Shortcut | Action |
|---|---|
| ⌘/Ctrl B | Show or hide the sidebar |
## Mac Resources charts {#mac-resources-charts}
With a chart focused on the [Mac Resources](/operate/scaling#mac-resources) page:
| Key | Action |
|---|---|
| ← → (or ↓ ↑) | Move to the previous or next reading |
| Home / End | Jump to the oldest or the latest reading |
| Esc | Hide the reading |
## Related {#related}
- [Device controls](/testing/device-controls): touch, gestures, device buttons and the clipboard in detail
- [QA Session](/testing/qa-session): everything a session offers
---
# https://www.tapflow.dev/reference/performance
# Performance & Latency
This page is a record of how tapflow streaming **actually measures**. To run the same measurements yourself, see [Reproduce it](#reproduce) below.
::: tip In one line
Bandwidth is not a bottleneck (it uses a single-digit percentage of a home Wi-Fi link), and decode latency on a real LAN is single-digit to a few tens of milliseconds. Glass-to-glass — finger to screen — adds a network round trip on top, and that sum is **estimated** to fit within the ~100 ms budget known as the limit where people feel they are "directly manipulating" objects1.
:::
## What we measured, and how
tapflow negotiates the codec per client. The path most teams actually use is **LAN (HTTP)**, which is not a secure context, so the browser uses the **WASM (tinyh264) software decoder**. The baseline numbers on this page are therefore that WASM path. On HTTPS (a secure context), hardware-accelerated **WebCodecs** is used and is faster, but it needs a certificate setup, so it is an optional path for now.
We track two distinct metrics.
| Metric | Definition | Valid where |
|--------|------------|-------------|
| **decode→present** | from the viewer receiving a frame, to decoding and drawing it on screen | valid everywhere (a delta within one machine) |
| **glass-to-glass** | from the moment the screen is captured, to it being shown in the viewer | **valid only on a single clock (localhost)** |
`glass-to-glass` is a subtraction between the capture time (agent machine) and the present time (viewer machine), so it cannot be measured directly on a LAN where the two machines run different clocks. On a LAN we therefore measure `decode→present` (valid in any environment) and treat the full perceived latency as an [estimate](#estimate).
## Bandwidth
The H.264 stream barely uses any bandwidth. A still screen is negligible, and it only spikes briefly in the worst case where the whole screen changes, such as scrolling.
| Scenario | Per frame | Bandwidth | vs JPEG |
|----------|-----------|-----------|---------|
| Still | ~1.8 KB | ~14 KB/s | ~140× less |
| Scroll | ~90–110 KB | ~2.6 MB/s | ~5× less |
The scroll peak of ~2.6 MB/s is about 21 Mbps. Given that IEEE 802.11ac (Wi-Fi 5) specifies a single-link throughput of **≥500 Mbps**2 and gigabit Ethernet is 1 Gbps, this uses only a single-digit percentage of one home Wi-Fi link. **Bandwidth is not a bottleneck.** These figures were measured before the iOS H.264 encoder got its default 8 Mbps bitrate cap (`TAPFLOW_IOS_H264_BITRATE`).
## Decode latency
A real-LAN measurement. The agent (build machine) and the viewer sit on **two different Macs** connected over the same LAN, and this is the mean of four repeated runs of the viewer-side WASM (tinyh264) decoder's `decode→present`.
| Scenario | p50 | p95 |
|----------|-----|-----|
| Still | 11.3 ms | 43.9 ms |
| Scroll | 16.6 ms | 49.9 ms |
For reference, on **localhost (single clock)** — with the network factored out — the full `glass-to-glass` can be measured, and there the WASM path was 9.6 ms still / 16 ms scroll, on par with localhost JPEG (the closest baseline to direct manipulation). In other words, decode itself floors at single-digit to low-double-digit milliseconds as the network approaches zero.
## End-to-end estimate {#estimate}
::: warning This is an estimate (not a measurement)
The following is a **component sum** — the measured `decode→present` plus published network-latency specs. The LAN `glass-to-glass` cannot be measured directly because of the two-clock problem described above, so we do not assert it.
:::
The full finger-to-screen latency is composed roughly of:
- **Decode** (measured on LAN): p50 11–17 ms, p95 ~44–50 ms
- **Network round trip**: wired LAN <1 ms; Wi-Fi 6 is in the single-digit ms range — OFDMA is reported to bring the median below 5 ms in non-saturation conditions3
- **Capture & encode** (agent side, measured on localhost): agent→relay ~1 ms, plus the capture cadence (iOS 30fps polling is 0–33 ms)
Comparing that sum against the **~100 ms limit where people feel they are "directly manipulating" objects**1, the share tapflow controls — decode and transport — takes up only part of that budget. Quality studies on the same class of remote interactive streaming (cloud gaming) likewise place the comfort limit around ~100 ms4.
The exact value varies by environment. The most honest approach is to **run `ping` on your operating LAN** and plug it into the network term above.
## Known limitations
- LAN `glass-to-glass` cannot be measured directly because the agent and viewer run different clocks. The perceived numbers above are estimates.
- Scroll **p95 climbs to ~50 ms**. In moments of heavy motion, more so than on a still screen, the budget can get tight. Measurements show this tail comes from load and transport, not the decoder.
- There is no real-LAN measurement of the HTTPS (WebCodecs) path yet — only a localhost proxy (3.9 ms still / 3.4 ms scroll `glass-to-glass`).
- About 5% of older browsers (no WebGL2) fall back to JPEG. Bandwidth rises, but it works.
- Resolution downscaling trades some fidelity for lower bandwidth and decode load. The default depends on the connection: native on localhost and LAN HTTPS, 1280px on the longest side for LAN HTTP, 1000px for an external connection ([`TAPFLOW_MAX_SIZE`](/reference/configuration#streaming-tuning-agent)).
- Android emulators are bound by a software H.264 encoder, which limits frame production. A host-encode path mitigates this, and real devices use a hardware encoder.
## Reproduce it {#reproduce}
Performance instrumentation is on only in the dev build (Vite `:3001`); you force a decoder via a URL query and read p50/p95 from the `?perf=1` panel.
```sh
pnpm --filter @tapflowio/dashboard dev
```
In the browser, append `?perf=1` to open the panel. Adding `?decoder=wasm` forces the WASM decoder even in a secure context such as localhost; without it the automatic pick is used: WebCodecs when the page is a secure context with WebCodecs and WebGL2, otherwise WASM when WebGL2 is available. Other values are ignored. To compare the JPEG path, set `TAPFLOW_IOS_CODEC=jpeg` on the agent. For a cross-machine LAN measurement, open the viewer on another Mac on the same LAN.
The full pipeline analysis, decoder selection process, and the accumulated measurement log and decision record remain in the engineering log as-is — [streaming-latency-log.md](https://github.com/jo-duchan/tapflow/blob/main/contributing/streaming-latency-log.md).
## References
1. Jakob Nielsen, "Response Time Limits" (Nielsen Norman Group). 0.1 s (100 ms) is the limit at which users feel they are directly manipulating UI objects, creating the illusion of an instantaneous response.
2. IEEE 802.11ac-2013. Single-link ≥500 Mbps, multi-station ≥1.1 Gbps.
3. "Experimental Evaluation of IEEE 802.11ax — Low Latency and High Reliability with Wi-Fi 6?" (IEEE) and "A First Look at Wi-Fi 6 in Action" (ACM). OFDMA lowers the median latency from ~5 ms to under 1 ms in non-saturation conditions.
4. Quality-of-experience research on cloud gaming finds that player QoE begins to degrade beyond a 100 ms latency threshold (Jarschel et al., 2011), as cited in G. Illahi et al., "Cloud Gaming With Foveated Graphics" (arXiv:1809.05823) §4.3.2.
## Related {#related}
- [Stream quality](/operate/streaming-quality): the stream profiles and how to switch to Smooth
- [Scaling Mac resources](/operate/scaling): how many devices one Mac can run
- [Sustainability](/reference/sustainability): the hardware side of reusing Macs
---
# https://www.tapflow.dev/reference/security
# Security & Privacy
tapflow is a self-hosted product. Build files, device streams, session recordings — all data stays inside your infrastructure. This page explains how tapflow protects your data by design.
## Data never leaves your network
tapflow does not route anything through an external cloud service.
| Data | Where it lives | Sent externally |
|------|----------------|-----------------|
| Build files (.app.zip / .tar.gz / .apk) | Local storage on the Mac running the relay | ❌ |
| Device stream (video · touch) | Browser ↔ relay ↔ agent — all internal | ❌ |
| Session recordings | Stored on the relay's Mac; expire after 72h and are purged automatically | ❌ |
| Logs | The Mac running the relay and agents | ❌ |
| Account & team data | SQLite DB on the relay's Mac | ❌ |
Unlike Appetize or BrowserStack, there is no step where you upload your app binary to an external server. The binary stays on your Mac.
## LAN-first architecture
tapflow's recommended deployment keeps the agent and relay **on the same LAN**.
```text
browser (anywhere) ──WAN──▶ relay ◀──LAN──▶ agent
│
└── SQLite DB, build files
```
The agent ↔ relay leg is LAN-internal traffic. Because the device stream never passes through an external service, your app's UI and behavior are not exposed outside your network.
To apply TLS to the browser ↔ relay leg (WAN), use a reverse proxy or tunnel in front of the relay. See [External access](/operate/external-access) for details. The relay can also terminate TLS itself through its [`tls` setting](/reference/configuration#https-secure-context).
## PAT-based authentication
Programmatic access to tapflow is controlled by **personal access tokens (PATs)**.
- Tokens are issued per user. Removing a team member deletes their tokens too.
- Each token carries a **scope** that limits what it can do:
- `builds:write` — upload builds, for CI/CD pipelines (issued from the dashboard under Settings → Tokens)
- `view` — read the app list, uploaded files, session screenshots and UI trees. Also required to open a device session over WebSocket from a remote machine.
- `agent` — connect an agent on a remote Mac to the relay. Only an Admin can issue one, and it works only while the member who issued it is still an Admin.
- Dashboard access for team members is governed separately by **roles** (Admin / Developer / QA / Viewer), not by PATs.
- A call made with a PAT is held to its owner's current role. Viewer is read-only, so a `builds:write` token owned by a Viewer cannot upload builds. The HTTP endpoints that check roles read the role on every request, so a role change applies there right away, for cookies and tokens alike.
- Open device sessions are covered as well. Removing a member, revoking a token, or changing the role of the Admin who issued an `agent` token makes the relay close the WebSocket connections opened with it at once. A connection whose token or sign-in session expires is closed within 30 seconds.
## Access control boundaries
Here is what tapflow handles and what you manage as the infrastructure operator.
**tapflow provides:**
- API authentication: build upload and listing, comments and webhooks accept a signed-in session or a `builds:write` PAT; the app list, uploaded files, screenshots and UI trees accept a signed-in session or a `view` PAT. The [REST API reference](/reference/api) lists which endpoints accept a PAT.
- Device stream (WebSocket) authentication: a remote connection needs a signed-in session or a PAT with the `view` scope, and a remote agent needs an `agent`-scope PAT issued by a current Admin.
- Relay logs (`GET /api/v1/logs`) are served only to the relay host.
- Sign-in for every connection that does not reach the relay port over loopback, including tunnel traffic, which arrives on a separate loopback port of its own
- No outbound data transmission to external services
**You are responsible for:**
- OS and network security of the Mac running the relay
- TLS on the WAN leg (reverse proxy or tunnel configuration)
- Network access control to the relay host (firewall, VPN, etc.)
- Managing `JWT_SECRET` and other environment variables for the relay
- Team separation: one relay serves one team, so run a separate relay for each team whose builds and streams must stay apart
- Who can manage webhooks: webhook destinations may be private LAN addresses by design, so an Admin, Developer or QA member, or a `builds:write` PAT, can have the relay send requests to hosts on your LAN. The request body is a fixed build-status event and the response is not shown to whoever registered it. If that matters on your network, decide who gets those roles accordingly.
::: tip Running on an internal network only
If the relay is only reachable within your internal LAN, you can operate without WAN-leg TLS. This is appropriate when every team member is on the same network — office Wi-Fi or a shared VPN.
:::
## Reporting a vulnerability
If you find a security issue in tapflow's code, please report it privately rather than opening a public issue. See [SECURITY.md](https://github.com/jo-duchan/tapflow/blob/main/SECURITY.md) for the full disclosure process.
## Related {#related}
- [Team, roles & tokens](/operate/team-and-roles): roles and personal access tokens in practice
- [External access](/operate/external-access): exposing the relay beyond the LAN
- [REST API](/reference/api): which endpoints accept which credentials
---
# https://www.tapflow.dev/reference/sustainability
# Sustainability
tapflow makes better use of the Macs a team already owns and lowers the need for new hardware, which makes mobile QA more sustainable.
A significant share of a phone or laptop's carbon is emitted during manufacturing rather than during use, as Apple's own [product environmental reports](https://www.apple.com/environment/reports/) show. This is embodied carbon, and [Software Carbon Intensity (SCI)](https://sci.greensoftware.foundation/), now an international standard, weighs it alongside energy consumption. Keeping existing hardware in service longer and buying fewer new devices can therefore help reduce the burden from manufacturing.
## Using Macs you already have
tapflow turns Macs that a team already owns, but does not use continuously, into shared QA infrastructure. Instead of building out a device farm or a dedicated test server, you make better use of hardware you already own.
## Less reliance on physical devices
Maintaining coverage across OS versions usually means keeping several devices and pinning each one to a specific version. On iOS this is especially common, since rolling back to an earlier version is effectively impossible. Simulators and emulators give you the same OS coverage without additional devices.
Simulators alone, though, do not carry that benefit across a team — only people with a development environment installed can use them. tapflow shares simulators and emulators through a browser, so PMs, designers, and backend engineers without a mobile development environment can work in the same test environment. That can reduce the need to add physical devices at the team level.
## Extending hardware lifetime
When new machines are issued, the ones they replace are left over. Running one as an agent host keeps it in service instead of sending it for disposal. A simulator host often depends more on having enough memory than on CPU speed, so a Mac a generation or two behind can still handle the role reliably. The agent does need an [Apple Silicon Mac](/operate/requirements), though.
Test devices and agent hosts also age on different clocks. Test devices are replaced on a schedule set by OS support windows and the need to validate new OS releases. An agent host faces little of that pressure and can stay in service until macOS stops supporting it. Over eight years, repeatedly replacing devices costs more than twice the manufacturing carbon of keeping one Mac.
## What we measured
We compared operating four test devices (3 iPhone + 1 iPad) against running four simulators on one Mac a team already owns. Manufacturing carbon is divided by each device's expected service life and expressed as an annual figure; emissions from electricity use Korea's grid factor of 417.3 gCO₂e/kWh (2023).
| | 4 test devices | tapflow |
|---|---:|---:|
| Manufacturing (annualised) | 55.5 kg | 0 kg |
| Electricity | 12.7 kg | 17.9 kg |
| Total | 68.2 kg/yr | 17.9 kg/yr |
Varying the assumptions does not change the outcome much: across every scenario, tapflow came out 3.4 to 4.3 times lower in annual CO₂e.
On electricity alone, tapflow uses more. Keeping a Mac powered draws more than charging a few test devices. What creates the difference in this comparison is not energy efficiency but the absence of manufacturing carbon, since no additional hardware is purchased.
Power measurements support this. With a session open, the added draw stayed within measurement noise, and only active interaction raised it by about 4.5 W, because the H.264 encoder has almost nothing to do while the screen is static. In real QA most of the time is spent looking at the screen and interaction happens intermittently, so average draw is lower still.
The formulas, input sources, measurement conditions, and the comparisons we considered and rejected are documented in [sustainability-carbon-math.md](https://github.com/jo-duchan/tapflow/blob/main/contributing/sustainability-carbon-math.md).
## Limits
- **It draws power.** A Mac left on running simulators consumes electricity. In the comparison above, the electricity line favours physical devices.
- **A Mac bought to be an agent host has an upfront cost.** Manufacturing carbon is zero only when you reuse a Mac you already have. If you do buy one, a Mac mini breaks even in roughly six months and a MacBook Pro in about three and a half years. Laptops carry a display and a battery, so their manufacturing carbon is far higher than a desktop's.
- **It does not replace every physical device.** tapflow cannot test features that depend on device hardware, such as camera, NFC, or biometrics.
- **Old Macs have a floor.** The iOS simulator requires a recent Xcode, which requires a recent macOS. A Mac past that line cannot run as an agent. See [Requirements](/operate/requirements).
- **Grid factors vary by region.** The figures above are for Korea. On a cleaner grid both electricity lines shrink, while the manufacturing comparison stays the same.
## Related {#related}
- [Scaling Mac resources](/operate/scaling): getting more devices out of the Macs you have
- [Performance & Latency](/reference/performance): what the stream costs in bandwidth and latency
---
# https://www.tapflow.dev/testing
# Test apps
This section is for teammates who check builds in the browser. tapflow itself needs nothing installed: all you need is the tapflow dashboard address and an account from whoever runs tapflow. If they open the dashboard to you through Tailscale, install the Tailscale app too ([Tailscale](/operate/external-access#tailscale-recommended)).
The dashboard is tapflow's web interface. The devices (iOS simulators and Android emulators) run on a Mac your operator set up, and you watch and drive them from your browser.
## Testing flow {#testing-flow}
1. Pick an app and a build in **App Center**. App Center is the dashboard's build list, unrelated to Microsoft App Center.
2. Press **Start QA** on the build's row to open a QA Session. A QA Session streams the device screen to your browser and sends your clicks and keystrokes to the device.
3. Pick a Mac and a device, and the build is installed on it. Launch the app, check it, and leave what you found as a comment or a recording.
4. Change the build's review status in App Center to tell the team the result.
## Pages in this section {#pages}
- [App Center](/testing/app-center): the build list, uploading builds, search and status filter, review status, and scheduled deletion.
- [QA Session](/testing/qa-session): picking a Mac and a device to start a session, and everything you can do in one.
- [Device controls](/testing/device-controls): touch, swipe, pinch, typing, device buttons, rotation, restart, and the clipboard.
- [Deep links](/testing/deep-links): open a specific screen of the app from a URL.
- [Network control](/testing/network-control): take the device offline and bring it back.
- [Audio](/testing/audio): hear the device in your browser.
- [Screenshots & recordings](/testing/screenshots-and-recordings): save the screen as an image or record it as a video.
- [Comments](/testing/comments): leave notes and images on a build for the team.
## Moved sections {#moved-sections}
Sections that used to be on this page now live on these pages.
- [App Center](/testing/app-center)
- [App Center](/testing/app-center#app-center)
- [QA Session](/testing/qa-session)
- [QA Session](/testing/qa-session#qa-session)
- [Scaling Mac Resources](/operate/scaling)
- [Mac Resources](/operate/scaling#mac-resources)
- [Team, roles & tokens](/operate/team-and-roles)
- [Settings](/operate/team-and-roles)
- [Team](/operate/team-and-roles#team)
- [Tokens](/operate/team-and-roles#tokens)
- [For teammates](/get-started/teammates)
- [Default](/get-started/teammates#default)
---
# https://www.tapflow.dev/testing/app-center
# App Center
App Center is the build list inside the tapflow dashboard. It has nothing to do with Microsoft App Center. The iOS and Android builds your team uploads collect here by app and version, and this is where you pick one and start a QA Session. It is the first screen you see after signing in.
## How to use {#how-to-use}
1. Pick an app from the **Apps** list on the left. An app is identified by its bundle ID (the iOS bundle identifier or the Android package name) and platform.
2. Builds are grouped by version name into collapsible releases. A build whose version name could not be read goes under **Unversioned**.
3. Press **Start QA** on the build you want to test. The build's [QA Session](/testing/qa-session) opens.
Each build row shows the build number, platform, review status badge, scheduled-deletion badge, uploader, and upload date.
### Find a build {#find-a-build}
- Type part of a version name into **Search version…** to see only builds whose version contains it.
- Pick **Backlog**, **In Progress**, **Done** or **Rejected** in the status filter (default **All statuses**) to see only builds with that status.
When nothing matches, the list shows **No matching builds**.
## Upload a build {#upload-a-build}
1. Press **Upload build** at the top right of the screen.
2. Click the **File** area to choose a file, or drag one onto it.
- iOS: a simulator build, `.app.zip` or `.tar.gz`/`.tgz`. A `.app.zip` is the `.app` folder [built from the command line](https://developer.apple.com/library/archive/technotes/tn2339/_index.html) and zipped. Upload a cloud simulator build's `.tar.gz`/`.tgz`, such as one from EAS, as it is.
- Android: `.apk`
3. Optionally pick a review status under **Status (optional)**. With the default, **None**, the build has no status.
4. Press **Upload**. A **Build uploaded** notice appears and the build joins the list.
::: warning iOS `.ipa` files are not supported
`.ipa` is the format for real devices. tapflow accepts `.app.zip` and `.tar.gz`/`.tgz` for simulators. If an upload fails, see [Builds & uploads troubleshooting](/troubleshooting/builds#ios-build-upload-errors).
:::
An uploaded build is linked to an app by its bundle ID. If no app has that bundle ID, a new one is created. Uploading while an app is selected links the build to that app. If the build's bundle ID differs from the selected app's, it goes to the app for its own bundle ID instead.
You can also create the app first with **Add App** below the **Apps** list, giving its **Name**, **Bundle ID** and **Platform** (iOS, Android or Both).
To upload builds automatically from a CI pipeline, see [Upload from CI](/operate/ci-distribution).
## Review status {#review-status}
The review status tells the team how far a build has been checked. Change it from the status menu on the build's row.
| Status | Meaning |
|---|---|
| — | No status |
| **Backlog** | Not yet ready |
| **In Progress** | Ready for review |
| **Done** | Stakeholders approved |
| **Rejected** | Issues found, needs fixes |
- A build marked **Done** has its **Start QA** button disabled. To test an approved build again, change its status first.
- In a QA Session, the status badge sits next to the breadcrumb at the top until you pick a Mac.
- When the status changes to **Done** or **Rejected**, any [review webhooks](/operate/webhooks) your operator registered are called.
## Schedule deletion {#schedule-deletion}
Schedule a build you no longer need for deletion with the trash icon (**Schedule deletion**). Press **Schedule deletion** in the confirmation and the build files are deleted after 7 days. The row gets a countdown badge such as **Deletes in 6d**.
Until then you can cancel it any time with the timer icon (**Cancel scheduled deletion**). Scheduled deletion is independent of the review status.
## Platform support {#platform-support}
| | iOS | Android |
|---|---|---|
| Upload file | `.app.zip`, `.tar.gz`/`.tgz` (simulator build) | `.apk` |
| What identifies the app | Bundle identifier | Package name |
## Limits {#limits}
- The Viewer role is read-only. When a Viewer presses **Upload build** or **Add App**, a notice says QA or Developer access is needed instead of opening the dialog. A Viewer sees no status menu and no deletion button, only **Start QA** and the status badge. For what each role can do, see [Team, roles & tokens](/operate/team-and-roles#team).
- The list shows each app's 100 most recently uploaded builds. Search by version to find older ones.
- App Center cannot rename or delete an app. The Admin, Developer and QA roles can do both from the **Apps** card in **Settings → Default**. Deleting an app deletes all of its builds.
- The deletion confirmation always says 7 days. If your operator changed `TAPFLOW_BUILD_TTL_DAYS` on the relay, the real period follows that value.
## Troubleshooting {#troubleshooting}
- If an upload fails with a `400` error, see [iOS build upload errors](/troubleshooting/builds#ios-build-upload-errors).
- If an APK shows as **Unversioned** or merges into another app, see [Builds & uploads](/troubleshooting/builds).
## Related {#related}
- [QA Session](/testing/qa-session): picking a Mac and a device after pressing **Start QA**
- [Upload from CI](/operate/ci-distribution): uploading builds automatically from a pipeline
- [Review webhooks](/operate/webhooks): notifying another system when a review status changes
---
# https://www.tapflow.dev/testing/audio
# Audio
tapflow streams the device's audio to the browser alongside video, so your whole team hears the app — not just sees it. App sound, web audio (e.g. a video playing in the in-app browser), and system sounds all come through.
## On by default
Audio is **on by default** on both iOS and Android. When a viewer opens a device in the browser, its audio plays in that browser tab.
Opt out per agent with an environment variable:
```bash
TAPFLOW_AUDIO=off tapflow agent start
```
That disables audio capture on both platforms; the video path is unchanged either way.
## Platform support {#platform-support}
| | iOS | Android |
|---|---|---|
| How the sound is captured | Core Audio process taps on the agent Mac | From the emulator's stream |
| Extra requirement | None — the iOS agent's macOS 26 requirement covers it ([Requirements](/operate/requirements)) | None |
| Agent Mac speakers | Silent | Silent on macOS 14.2 or later; audible below that |
## Setup (operator) {#setup-operator}
### Permission (one-time)
Both iOS audio capture and the Android host-mute below use a macOS audio-recording permission, so the first time it's needed macOS shows a one-time prompt.
`tapflow agent start` (and `tapflow setup ios`) request it up front, so an unattended operator can grant it while present instead of missing it at first boot. **If browser audio is silent, re-run `tapflow agent start`** — the prompt appears again.
::: tip
The agent operator (whoever runs the Mac), not the dashboard viewer, grants this permission.
:::
### The agent Mac stays quiet
When audio is on, the device's sound plays **only in the browser** — the agent Mac's own speakers stay silent, so there's no echo or noise on a shared or unattended Mac:
- **iOS** — the audio is muted at the capture source.
- **Android** (macOS 14.2+) — a mute-only tap silences the emulator's host output. Below macOS 14.2 the emulator is audible on the agent Mac; lower the Mac's volume.
Other apps on the agent Mac are unaffected.
## Volume
The device's own volume is reflected in what the browser hears — change the simulator/emulator volume and the streamed audio follows.
## Troubleshooting
**No audio in the browser**
- Re-run `tapflow agent start` to re-trigger the permission prompt, then click Allow.
- Confirm `TAPFLOW_AUDIO` isn't set to `off`.
**The agent Mac plays the emulator's sound (Android)**
- Expected below macOS 14.2 — lower the Mac's volume. On macOS 14.2+ it's muted automatically.
## Related {#related}
- [QA Session](/testing/qa-session): everything a session offers
- [Streaming quality](/operate/streaming-quality): how the video side is tuned
---
# https://www.tapflow.dev/testing/comments
# Comments
A comment is a note on a build. Write down what you found while testing, with a screenshot, and every teammate who opens the same build sees it. Every role can comment, and there is nothing to turn on.
## How to use {#how-to-use}
1. In a [QA Session](/testing/qa-session), open the **Comments** tab under the info card. It is the tab that shows first.
2. Write in the **Leave a comment…** box.
3. To add an image, press **Attach image** and choose a file.
4. Press **Post comment**.
Comments form one flat list with no threads, grouped under **Today**, **Yesterday** and earlier dates. Each shows its author and time, and clicking an attached image opens it in a new tab.
### Share a link to a comment {#comment-links}
Press the link button next to a comment (**Copy link to comment**) to copy an address that goes straight to it, and a **Link copied** notice appears. The address ends in `#comment-`. Whoever opens it is scrolled to that comment, which is highlighted for a moment.
## Platform support {#platform-support}
Comments belong to the build, not the device, so they work the same for iOS and Android builds.
## Limits {#limits}
- Attached images must be PNG, JPG or WebP, up to 5 MB. Another format shows **Only png, jpg, webp allowed**, and a larger file shows **Max 5MB**.
- The dashboard cannot edit or delete a comment. Deleting one takes the [REST API](/reference/api#delete-api-v1-comments-id), and only its author or an Admin can.
- Copying a comment link works only when the dashboard is open over HTTPS (or `localhost`). Over HTTP the browser blocks the clipboard write and **Could not copy link** appears.
## Setup (operator) {#setup-operator}
CI can leave the branch or commit as a comment while it uploads a build; see [Upload from CI](/operate/ci-distribution). The attachment limit can be changed on the relay with `TAPFLOW_MAX_COMMENT_BYTES`, but the dashboard still refuses files over 5 MB before uploading them.
## Troubleshooting {#troubleshooting}
- **Couldn't load comments.** means the list could not be fetched. Reload the page.
- **Could not copy link** appears. Check whether the dashboard address starts with `http://`, and ask your operator to set up HTTPS.
## Related {#related}
- [Screenshots & recordings](/testing/screenshots-and-recordings): capturing the screen to attach to a comment
- [App Center](/testing/app-center#review-status): changing a build's review status once it is checked
- [Upload from CI](/operate/ci-distribution): leaving comments from CI
---
# https://www.tapflow.dev/testing/deep-links
# Deep links
A deep link is a URL that opens a specific screen of an app, such as `myapp://settings`. Enter one in a QA Session and you land on the screen you want to check instead of tapping your way there from the first screen. There is nothing to turn on.
## How to use {#how-to-use}
1. On a device with the app installed, press the link icon in the toolbar (tooltip **Deeplink**, accessible name **Open a deeplink**), or press ⌘ K.
2. Type the URL into the box (**Deeplink URL**).
3. Press Enter or **Open**.
When the device opens the URL, a **Deeplink opened** notice appears. If it fails, the notice shows the error message the device sent back. The box is empty each time it opens.
## Platform support {#platform-support}
| | iOS | Android |
|---|---|---|
| Supported | Yes | Yes |
| How the URL is opened | Asks the simulator to open the URL (`simctl openurl`) | Sends the emulator a view intent for the URL (`am start -a android.intent.action.VIEW`) |
On both platforms the device picks the app that handles the URL, as it would for a link tapped on a phone.
## Limits {#limits}
- The app that handles the URL must be installed on the device and must have registered its URL scheme. [Launch the app](/testing/device-controls#launch-the-app) first to be sure the install has finished.
- ⌘ K does not fire while the cursor is in another of the dashboard's text fields.
## Troubleshooting {#troubleshooting}
- **The notice appeared but the screen you wanted did not open.** The app is what reads the URL. Check with its developers that it handles that path. On Android, **Deeplink opened** can also appear when no app on the device registered the URL scheme.
- **A `no booted device` or `No booted device` error appears.** The device is still booting, or the session has dropped. Try again once the progress message on the info card is gone.
- **The session keeps dropping.** See [Stream & sessions](/troubleshooting/streaming).
## Related {#related}
- [QA Session](/testing/qa-session): everything a session offers, and keyboard shortcuts
- [Device controls](/testing/device-controls): launching the app and device buttons
---
# https://www.tapflow.dev/testing/device-controls
# Device controls
You drive the device in a [QA Session](/testing/qa-session) with your mouse and keyboard, much like a phone in your hand. There is nothing to turn on: it works as soon as the session starts. The buttons are in the toolbar to the right of the device, and hovering one shows its name and shortcut.
## Touch and gestures {#touch-and-gestures}
- **Tap**: click the device screen.
- **Swipe**: click and drag.
- **Pinch**: hold Option (Alt) and drag. Pressing the key shows where the two fingers are, and dragging moves them apart or together. Releasing the key ends the pinch.
## Typing {#typing}
Click the device screen once and your keystrokes go to the device from then on. The **Focus** label on the info card turns green while the device is taking your input. Clicking anywhere in the dashboard outside the device stops it.
## Launch the app {#launch-the-app}
Once the device is up, the build is installed on it automatically. When the install finishes, a **Launch app** button with a play icon appears at the top of the toolbar. Press it to run the app. Use the same button to start the app again after closing it.
## Device buttons {#device-buttons}
| Platform | Buttons |
|---|---|
| iOS | **Home** in the toolbar (⌘ ⇧ U), and the side buttons drawn on the device frame |
| Android | **Home**, **Back**, **Recent Apps**, **Volume Up**, **Volume Down** and **Power** in the toolbar |
On iOS, clicking a side button drawn on the device frame (power, volume and so on) presses it.
## Software keyboard {#software-keyboard}
The keyboard button in the toolbar (**Software keyboard**, ⌘ ⇧ K) shows or hides the iOS simulator's on-screen keyboard. Use it to check a layout the on-screen keyboard covers. If the device does not answer, a notice says the keyboard stayed where it was.
## Rotate {#rotate}
Each press of **Rotate** (⌘ ⇧ O) turns the device between portrait and landscape. Holding the shortcut down turns it once. If you leave the session in landscape, the device is turned back to portrait before it shuts down.
A session starts in portrait. If the device was left in landscape, for example after you reloaded the page or the agent restarted, it is turned upright when the session starts. The exception is an Android emulator set to auto-rotate that you turned outside tapflow, which is left as it is.
## Fold and unfold {#fold}
A foldable Android emulator gets a fold button in the toolbar. Each press switches between folded and unfolded, and the button's name is **Unfold** or **Fold** followed by the current posture and the one it will change to. While the posture changes, the button shows a spinner.
## Restart the device {#restart}
**Restart the device** opens a confirmation (**Restart this device?**). Press **Restart** and the device shuts down and boots again.
- Anything open on the device closes, and whatever you had set up on screen is gone.
- The build under test is installed again once the device is back, which clears that app's data.
- Other apps and their data stay.
To erase all of the device's data, turn on **Full reset** before picking the device (see [QA Session](/testing/qa-session#select-device)).
## Clipboard {#clipboard}
The clipboard works while your keystrokes are going to the device, after you click its screen.
- **Device to your computer**: select text on the device and press ⌘ C (or Ctrl C) to copy it to your computer's clipboard. X cuts instead.
- **Your computer to the device**: paste text you copied on your computer into a field on the device with ⌘ V (or Ctrl V).
If the dashboard is open over plain HTTP, text copied on the device stays on the device and the notice **Copied on the device. Serving the dashboard over HTTPS also brings it to your clipboard.** appears. Browsers allow this kind of clipboard write only on a secure connection (HTTPS or `localhost`). Ask your operator to set up HTTPS.
## Platform support {#platform-support}
| Feature | iOS | Android |
|---|---|---|
| Tap, swipe, pinch, typing | Yes | Yes |
| Device buttons | Home, side buttons on the device frame | Home, Back, Recent Apps, Volume Up, Volume Down, Power |
| Software keyboard button | Yes | No |
| Rotate | Yes | Yes |
| Fold and unfold | No | Foldable emulators with two postures |
| Restart, clipboard | Yes | Yes |
## Limits {#limits}
- Shortcuts use ⌘ (Command). Only the clipboard also accepts Ctrl.
- Shortcuts do not fire while the cursor is in one of the dashboard's text fields.
- ⌘ C pressed while text is selected in the dashboard is the browser's own copy, not the device's.
- Text pasted into the device is limited to 1 MB. Anything larger shows **That text is too large to send to the device**.
## Troubleshooting {#troubleshooting}
- **Keys do not reach the device.** Click the device screen once and check that **Focus** is green.
- **Text copied on the device is not on your clipboard.** If the dashboard address starts with `http://`, the copy stays on the device, as described under [Clipboard](#clipboard).
- **The device is taking too long — try again** appears. The device did not answer in time; try again.
- **The stream lags, or inputs arrive late.** See [Stream & sessions](/troubleshooting/streaming#stream-lag).
## Related {#related}
- [QA Session](/testing/qa-session): starting a session, the feature list, and keyboard shortcuts
- [Deep links](/testing/deep-links): opening a specific screen of the app from a URL
- [HTTPS (secure context)](/reference/configuration#https-secure-context): the HTTPS setup the clipboard and the Smooth profile need
---
# https://www.tapflow.dev/testing/network-control
# Network control
Pressing the network control button in the toolbar takes the simulator or emulator offline, and pressing it again brings it back online. Offline banners, retry handling and anything else that only appears with no connection can be checked from the browser.
The button works per simulator or emulator. Other devices running on the same agent Mac, and the agent Mac's own network, are unaffected.
**A request it cannot carry out is refused.** Telling the app it is offline without actually blocking its traffic would leave every request succeeding behind a screen that says otherwise, and offline behaviour checked in that state has not been checked at all. So tapflow applies all of it or none of it, and the button says why.
## How to use {#how-to-use}
1. In a [QA Session](/testing/qa-session), press **Take device offline** in the toolbar.
2. Send a new request from the app — open a screen or pull to refresh — and check the offline behaviour.
3. Press **Bring device online** to restore the network.
## What is cut, and what is not
Taking a device offline blocks every connection it **opens from then on**, except over the agent Mac's own loopback. Connections that were already open are cut as well, but only inside the app tapflow launched.
On iOS, the network filter on the Mac judges new connections only — a connection it has already allowed cannot be taken back. So already-open sockets are cut from inside the app instead, which works only in the app tapflow launched. Anything else keeps using the connections it already has: the simulator's browser, or an app you did not launch through tapflow.
In practice it looks like this: a screen that was already downloading when you pressed the button carries on for a moment, while a screen opened at that same moment fails right away. Both are normal.
**Check with a new request.** Open a screen or pull to refresh so the app sends something new. Judging by watching a request that was already in flight finish makes it look as though offline did not take.
## Localhost is not cut
A dev server such as Metro reaches the device over the agent Mac's loopback, so it stays connected while the device is offline. You can watch offline behaviour with a debug build still attached.
## Platform support {#platform-support}
| | iOS | Android |
|---|---|---|
| How the device goes offline | The tapflow network extension on the agent Mac blocks the simulator's new connections | The emulator's airplane mode is switched on |
| Setup | Install the network extension once on the agent Mac (below) | None |
## Limits {#limits}
::: warning A hybrid app's WebView draws no offline banner
Screens running inside a WebView are not reached by the offline notification, so no banner appears. This is a known limitation.
**Connections a WebView already had open are not cut either.** Cutting open sockets works only inside the app tapflow launched, and a WebView's networking runs in separate processes that it does not reach. Connections the WebView opens after that fail like any other request.
Confirm offline behaviour on those screens by a **new request** failing, rather than by a banner.
:::
::: tip Error codes differ from a real device
The error an app sees is not the one a real device in airplane mode gives. Observed on an iOS simulator: `URLSession` reports `NSURLErrorNetworkConnectionLost` (`-1005`) rather than `NSURLErrorNotConnectedToInternet` (`-1009`), a low-level socket `connect()` fails with `EBADF`, and Safari shows an unknown error. These are examples, not a fixed list: if your app branches on the error code, handle `-1005` and `-1009` both, and treat any other failure as offline too.
:::
## Setup (operator) {#setup-operator}
### iOS needs the network extension
Network control on an iOS simulator requires the tapflow network extension to be installed once **on the agent Mac**. It installs on the Mac, not in the simulator: a simulator has no radio to switch off and shares the Mac's network.
The extension comes with tapflow. The install and approval steps are in [iOS network extension](/operate/network-extension#network-not-set-up); both happen on the agent Mac, and approving it needs an administrator password.
### What you are trusting
This extension is handed **every connection the simulator opens, before the traffic leaves the Mac**, and decides whether it goes through. It is the largest trust tapflow asks for, so here it is plainly.
- **What it sees** — that a connection attributed to a simulator exists, and where it is going. It does not read contents.
- **What leaves the Mac** — nothing. Every decision is made on the Mac, and the state file the extension writes is local.
- **Who signs it** — tapflow's Developer ID, notarized by Apple.
**What a signature proves, and what it does not.** These two commands confirm the app is tapflow's and has not been tampered with since:
```sh
codesign -dv --verbose=4 /Applications/TapflowNetFilter.app
spctl -a -vv /Applications/TapflowNetFilter.app
```
What they do **not** prove is that this binary was built from the Swift committed to the repository. The app is built on a maintainer's Mac and committed, and the signing key deliberately does not live in CI, because otherwise anyone who can push a tag could sign a network filter. The price of that choice is a build nobody can reproduce. To check the source-to-binary link yourself, read the sources and build it: that needs a paid Apple Developer account.
**Switching it off and removing it are different things.** System Settings → General → Login Items & Extensions → Network Extensions turns it off and leaves it installed. To remove it, first stop tapflow on this Mac (`tapflow start`, `tapflow agent start`) — a running iOS agent switches the filter back on. Then switch the filter off and delete the app:
```sh
/Applications/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter --off
rm -rf /Applications/TapflowNetFilter.app
```
Last, click the ⋯ button beside TapflowNetFilter on that same screen, choose **Delete Extension**, and restart the Mac. It stays in that list after the app is gone, so it can still be deleted there. The removal finishes at the restart; until then `systemextensionsctl list` shows it as `terminated waiting to uninstall on reboot`. The filter goes off first because deleting one that is on can block the Mac's new connections.
`systemextensionsctl uninstall` is not an option. macOS refuses it on any Mac with System Integrity Protection (SIP) on.
Deleting `/Applications/TapflowNetFilter.app` on its own does not remove it. macOS keeps running an extension whose container app is gone, and `tapflow doctor ios` reports that state separately.
## Troubleshooting {#troubleshooting}
### When the button says why
A device that cannot be taken off the network draws the button in the failure colour along with what to do about it.
| What the button says | What to do |
|---|---|
| Launch an app | Launch an app through tapflow. Traffic can already be cut; telling the app needs it running under tapflow |
| Restart the device | Restart the device. Nothing was set up for it on this boot |
| It could not be confirmed — try again | Press it again. This appears while a device is booting, or when the connection to it drops briefly |
| tapflow could not read the network state — try again | Press it again. The button shows neither on nor off because tapflow has not read this device yet, which happens on Android while it boots or when the connection to it drops |
| The device did not change when asked | Press it again. If it keeps happening, that device will not take the setting — use another one |
| tapflow cannot tell this app it is off the network | Pressing again gives the same answer. Try launching a different app, and check the `Network hook` lines of `tapflow doctor ios` on the agent Mac |
| This Mac is not set up for it | See [iOS needs the network extension](#ios-needs-the-network-extension) above. It is an install step on the agent Mac |
**Pressing again is only worth it where it says to try again.** The rest answer the same way however many times they are pressed.
If a notice tells you the device went back on the network on its own while you were checking, the offline behaviour you have checked so far needs checking again. [Troubleshooting](/operate/network-extension#network-stopped) covers the cause and what to do.
## Related {#related}
- [QA Session](/testing/qa-session): everything a session offers
- [iOS network extension](/operate/network-extension): installing, approving and troubleshooting the extension
---
# https://www.tapflow.dev/testing/qa-session
# QA Session
A QA Session is where you watch and drive an iOS simulator or Android emulator running on a Mac, straight from your browser. The device runs on your operator's Mac, so teammates install nothing. It opens when you press **Start QA** on a build's row in [App Center](/testing/app-center).
## Start a session {#start-a-session}
### Pick a Mac {#select-mac}
**Select Mac** shows a card for each Mac that has devices for this platform. Each card has the Mac's name, a status dot, its free slots (free/total, such as `2/4 slots`), and its CPU and RAM usage.
| Status dot | Meaning |
|---|---|
| Green | Plenty of headroom |
| Yellow | CPU or RAM from 70% up to 80% |
| Red | CPU or RAM over 80%. The card cannot be picked |
| Grey | No usage report, or none for over 30 seconds. The card shows **Stale** |
Hovering a red card shows **This Mac is currently overloaded. Try again later.** Pick another Mac or try again shortly.
### Pick a device {#select-device}
Click a device under **Select device** to start the session. Each device shows its state.
- **Booted**: already running.
- **Available**: shut down and free. Picking it boots it.
- **In use**: another teammate is using it, so it cannot be picked.
Find a device by name with **Search device…**. Narrow the list with the OS version filter (default **Any version**).
Turn on **Full reset** before picking a device to erase all of its data first, other apps included. Even with it off, the build under test is installed fresh at every session start, so that app always starts with its data cleared. It applies once: the switch turns itself off the moment you pick a device. The switch appears only when the Mac supports resetting.
### Launch the app {#launch}
Once the device is up, the build is installed on it automatically. The info card to the right of the device shows progress such as **Starting device…** and **Installing app…**. When the install finishes, a **Launch app** button appears in the toolbar; press it to run the app. See [Device controls](/testing/device-controls#launch-the-app).
### Leave a session {#leave-a-session}
The breadcrumb at the top reads `app name › build › Mac › device`. Click an earlier step to go back to it. Leaving the device screen shuts that device down, so another teammate can use it straight away. Closing the browser tab does the same.
## What you can do in a session {#features}
| Feature | What it does |
|---|---|
| [Touch and gestures](/testing/device-controls#touch-and-gestures) | Click to tap, drag to swipe. Hold Option (Alt) and drag to pinch. |
| [Typing](/testing/device-controls#typing) | Click the device screen once, then type. |
| [Launch the app](/testing/device-controls#launch-the-app) | Run the installed build with **Launch app**. |
| [Device buttons](/testing/device-controls#device-buttons) | Press iOS Home and the side buttons on the device frame, or Android **Home**, **Back**, **Recent Apps**, **Volume Up**, **Volume Down** and **Power**. |
| [Software keyboard](/testing/device-controls#software-keyboard) | Show or hide the iOS on-screen keyboard. |
| [Rotate](/testing/device-controls#rotate) | Turn the device between portrait and landscape. |
| [Fold and unfold](/testing/device-controls#fold) | Fold or unfold a foldable Android emulator. |
| [Restart the device](/testing/device-controls#restart) | Restart the device. The build under test is reinstalled; other apps keep their data. |
| [Clipboard](/testing/device-controls#clipboard) | Copy and paste text between the device and your computer. |
| [Deep links](/testing/deep-links) | Open a specific screen of the app from a URL. |
| [Network control](/testing/network-control) | Take the device offline and bring it back. |
| [Audio](/testing/audio) | Hear the device in your browser. |
| [Screenshots](/testing/screenshots-and-recordings#screenshots) | Download the device screen as a PNG. |
| [Recordings](/testing/screenshots-and-recordings#recordings) | Record the device screen as a video and share it with the team. |
| [Comments](/testing/comments) | Leave notes and images on the build. |
| [Keyboard shortcuts](#keyboard-shortcuts) | Run common actions with one key combination. |
The buttons sit in the toolbar to the right of the device, in four groups: Navigation, Device, Capture and Environment. A button for a feature the Mac does not support is not shown.
## Info card {#info-card}
The info card to the right of the device shows the session's state.
- **Focus**: turns green while your keystrokes go to the device.
- **fps**: frames per second arriving. Above 15 it reads **Active**, otherwise **Idle**. A low number is normal while the screen is still.
- **Smooth** / **Standard**: the stream profile this browser gets. Click **Standard** for a dialog on what it takes to switch to Smooth. See [Stream quality](/operate/streaming-quality) for the profiles.
The tabs under the card hold [Comments](/testing/comments) (**Comments**) and [Recordings](/testing/screenshots-and-recordings#recordings) (**Recordings**).
## Keyboard shortcuts {#keyboard-shortcuts}
These shortcuts do not fire while the cursor is in one of the dashboard's text fields.
| Shortcut | Action | Platform |
|---|---|---|
| ⌘ K | Open the [deep link](/testing/deep-links) box | iOS, Android |
| ⌘ S | [Screenshot](/testing/screenshots-and-recordings#screenshots) | iOS, Android |
| ⌘ ⇧ Y | Start or stop a [recording](/testing/screenshots-and-recordings#recordings) | iOS, Android |
| ⌘ ⇧ O | [Rotate](/testing/device-controls#rotate) | iOS, Android |
| ⌘ ⇧ U | [Home button](/testing/device-controls#device-buttons) | iOS |
| ⌘ ⇧ K | Show or hide the [software keyboard](/testing/device-controls#software-keyboard) | iOS |
| ⌥ + drag | [Pinch](/testing/device-controls#touch-and-gestures) | iOS, Android |
| ⌘/Ctrl C·X·V | [Clipboard](/testing/device-controls#clipboard) copy, cut and paste | iOS, Android |
Any other key goes to the device once you have clicked its screen.
## Limits {#limits}
- One person uses a device at a time. A device someone else is using shows **In use** and cannot be picked.
- Leaving the device screen shuts the device down. Whatever was open on screen is gone and other apps keep their data. The build under test is installed again when the next session starts, so its data does not carry over.
## Troubleshooting {#troubleshooting}
When a session drops, the dashboard says why in a notice and returns to the Mac list.
| Notice | Cause and what to do |
|---|---|
| **`The agent disconnected — this session ended.`** | The Mac's connection dropped. Pick the Mac again to start a new session. |
| **This device is already open in another browser session.** | Someone else is using the device. If you reloaded while your connection was down, your own earlier tab is holding it, and it clears within about 45 seconds. |
| **That Mac is too busy to start a session.** | The Mac's CPU or RAM is over the limit. Pick another Mac or wait a moment. |
If the stream stalls or lags, see [Stream & sessions troubleshooting](/troubleshooting/streaming).
## Related {#related}
- [App Center](/testing/app-center): picking a build and changing its review status
- [Device controls](/testing/device-controls): touch, buttons, rotation, the clipboard and the rest
- [Stream quality](/operate/streaming-quality): how the Smooth and Standard profiles differ
---
# https://www.tapflow.dev/testing/screenshots-and-recordings
# Screenshots & recordings
Keep what you saw in a [QA Session](/testing/qa-session) as an image or a video, for a bug report or a [comment](/testing/comments). Both are in the toolbar's Capture group, and there is nothing to turn on.
## Screenshots {#screenshots}
Press the camera button in the toolbar (**Screenshot**) or ⌘ S and the current device screen downloads as `tapflow-.png`. Your browser saves the file straight to your computer; it is never uploaded to the tapflow server (the relay).
## Recordings {#recordings}
1. Press the record button in the toolbar (**Start recording**) or ⌘ ⇧ Y. The button turns red while recording.
2. Press it again (**Stop recording**) to stop, and the video is saved to the relay. The button shows a spinner while it saves.
3. When saving finishes, the video also downloads to your computer automatically.
Recordings collect in the build's **Recordings** tab. Open the **Recordings** tab under the info card to see each recording's time, how long it is kept for (such as **Expires in 2d**), and its size, and download it with the **Download** button. Every teammate who opens the same build sees this list.
The video format depends on your browser: `.mp4` where the browser can record MP4 (H.264), `.webm` otherwise.
## Platform support {#platform-support}
| | iOS | Android |
|---|---|---|
| Screenshots | Yes | Yes |
| Recordings | Yes | Yes |
## Limits {#limits}
- Recordings are kept for 72 hours after they are made. An expired recording leaves the list, and the relay deletes the files once a day. Download anything you want to keep.
- Switching to another browser tab or minimising the window while recording stops the recording and saves it.
- A recording holds the device screen only, not the device's sound.
- Screenshots are not shared with the team automatically. To share one, attach it to a [comment](/testing/comments).
## Troubleshooting {#troubleshooting}
- **A recording is missing from the Recordings tab.** You may have left the page while it was saving, or the save failed. Check the list again after the record button is back to normal.
- **Couldn't load recordings.** means the list could not be fetched. Reload the page.
## Related {#related}
- [QA Session](/testing/qa-session): everything a session offers, and keyboard shortcuts
- [Comments](/testing/comments): attaching a screenshot to share it with the team
---
# https://www.tapflow.dev/troubleshooting
# Troubleshooting
Find the symptom below, or start with `tapflow doctor` on the Mac where it happens.
## Start with `tapflow doctor` {#start-with-doctor}
On the agent Mac, `tapflow doctor` checks Node.js, the port, Xcode and the simulator, the Android SDK and an AVD, and prints what to fix for each failure. Pass `ios` or `android` to check one platform. The checks are listed in the [CLI reference](/reference/cli#tapflow-doctor), and each failure is explained under [`tapflow doctor` failures](/troubleshooting/install-and-agents#tapflow-doctor-failures).
```sh
tapflow doctor
tapflow doctor ios
```
## Find your symptom {#symptoms}
| Symptom | See |
|---|---|
| An agent does not appear in the dashboard, or cannot connect | [Agent cannot connect to the relay](/troubleshooting/install-and-agents#agent-cannot-connect-to-the-relay) |
| `tapflow agent start` says an agent is already running | [It says an agent is already running](/troubleshooting/install-and-agents#agent-already-running) |
| The relay reads a configuration or database you did not expect | [Unexpected configuration or database](/troubleshooting/install-and-agents#the-relay-is-using-a-configuration-or-database-you-did-not-expect) |
| Opening an iOS build fails with `spawn unknown error` | [iOS simulator](/troubleshooting/ios-simulator#spawn-unknown-error) |
| The iOS simulator does not boot | [Service version mismatch](/troubleshooting/ios-simulator#ios-simulator-service-version-mismatch), [cannot be located on disk](/troubleshooting/ios-simulator#simulator-data-missing) |
| The Android stream does not start | [Android emulator](/troubleshooting/android-emulator#stream-does-not-start-or-encoder-crashes) |
| An upload fails with `400` | [Builds & uploads](/troubleshooting/builds#_400-error-on-upload) |
| An APK does not install on an Apple Silicon Mac (`INSTALL_FAILED_NO_MATCHING_ABIS`) | [Builds & uploads](/troubleshooting/builds#install-failed-no-matching-abis-—-apk-not-compatible-with-apple-silicon-emulator) |
| The stream lags, stutters or looks blurry | [Stream & sessions](/troubleshooting/streaming#stream-lag) |
| A session ends by itself | [Session ends automatically](/troubleshooting/streaming#session-ends-automatically) |
| A device will not go offline | [Network control](/testing/network-control#troubleshooting) |
| An invitation or password reset link does not work | [Sign-in & accounts](/troubleshooting/accounts) |
| Everyone was signed out after an upgrade | [Signed out after upgrading](/troubleshooting/accounts#signed-out-after-upgrading) |
## View the relay's logs {#viewing-logs}
On the relay host, inspect its activity with the commands below. The relay does not show its logs to other machines.
```sh
tapflow logs
tapflow logs --lines 200
```
Running under Docker, a CLI outside the container counts as remote; use `docker compose logs` instead.
## Moved sections {#moved-sections}
Sections that used to be on this page now live on these pages.
- [Install & agents](/troubleshooting/install-and-agents)
- [Agent connection issues](/troubleshooting/install-and-agents#agent-connection-issues)
- [It says an agent is already running](/troubleshooting/install-and-agents#agent-already-running)
- [Agent cannot connect to the relay](/troubleshooting/install-and-agents#agent-cannot-connect-to-the-relay)
- [`tapflow doctor` failures](/troubleshooting/install-and-agents#tapflow-doctor-failures)
- [All iOS checks fail](/troubleshooting/install-and-agents#all-ios-checks-fail)
- [`Xcode not found` — Xcode is not installed](/troubleshooting/install-and-agents#xcode-not-found-—-xcode-is-not-installed)
- [`Xcode not found` — Xcode is installed but `xcode-select` is not configured](/troubleshooting/install-and-agents#xcode-not-found-—-xcode-is-installed-but-xcode-select-is-not-configured)
- [No simulator is running](/troubleshooting/install-and-agents#no-simulator-is-running)
- [`adb not found`](/troubleshooting/install-and-agents#adb-not-found)
- [`tapflow init` says `CONFIG KEPT`](/troubleshooting/install-and-agents#tapflow-init-says-config-kept)
- [The relay is using a configuration or database you did not expect](/troubleshooting/install-and-agents#the-relay-is-using-a-configuration-or-database-you-did-not-expect)
- [iOS simulator](/troubleshooting/ios-simulator)
- [Opening a build fails with `spawn unknown error`](/troubleshooting/ios-simulator#spawn-unknown-error)
- [iOS Simulator service version mismatch](/troubleshooting/ios-simulator#ios-simulator-service-version-mismatch)
- [iOS Simulator fails to boot — "cannot be located on disk"](/troubleshooting/ios-simulator#simulator-data-missing)
- [iOS 17 and earlier — Korean text splits into individual characters](/troubleshooting/ios-simulator#ios-17-and-earlier-—-korean-text-splits-into-individual-characters)
- [Builds & uploads](/troubleshooting/builds)
- [iOS build upload errors](/troubleshooting/builds#ios-build-upload-errors)
- [`400` error on upload](/troubleshooting/builds#_400-error-on-upload)
- [`INSTALL_FAILED_NO_MATCHING_ABIS` — APK not compatible with Apple Silicon emulator](/troubleshooting/builds#install-failed-no-matching-abis-—-apk-not-compatible-with-apple-silicon-emulator)
- [An APK upload shows as 'Unversioned' or merges into the wrong app](/troubleshooting/builds#an-apk-upload-shows-as-unversioned-or-merges-into-the-wrong-app)
- [Android emulator](/troubleshooting/android-emulator)
- [Android emulator issues](/troubleshooting/android-emulator)
- [Stream does not start or encoder crashes](/troubleshooting/android-emulator#stream-does-not-start-or-encoder-crashes)
- [Colors look different from the emulator (less saturated)](/troubleshooting/android-emulator#colors-look-different-from-the-emulator-less-saturated)
- [Emulator is slow when the Mac is unattended](/troubleshooting/android-emulator#emulator-is-slow-when-the-mac-is-unattended)
- [iOS network extension](/operate/network-extension)
- [iOS: the network extension is not installed](/operate/network-extension#network-not-set-up)
- [1. Install it](/operate/network-extension#_1-install-it)
- [2. Approve it](/operate/network-extension#_2-approve-it)
- [3. When a restart is needed](/operate/network-extension#_3-when-a-restart-is-needed)
- [Checking what the Mac has](/operate/network-extension#checking-what-the-mac-has)
- [If it still does not work](/operate/network-extension#if-it-still-does-not-work)
- [iOS: the Mac lost its network while the filter was being replaced](/operate/network-extension#network-lost-on-replace)
- [iOS: a device that was offline came back on the network by itself](/operate/network-extension#network-stopped)
- [Stream & sessions](/troubleshooting/streaming)
- [Session issues](/troubleshooting/streaming#session-issues)
- [Session ends automatically](/troubleshooting/streaming#session-ends-automatically)
- [Stream lag or stuttering](/troubleshooting/streaming#stream-lag)
- [Prefer a wired LAN](/troubleshooting/streaming#prefer-a-wired-lan)
- [Periodic hitching every ~0.5s on Wi-Fi (AWDL)](/troubleshooting/streaming#periodic-hitching-every-0-5s-on-wi-fi-awdl)
- [Host CPU / RAM pressure](/troubleshooting/streaming#host-cpu-ram-pressure)
- [Display sleep](/troubleshooting/streaming#display-sleep)
- [Blurry or low-resolution stream on LAN](/troubleshooting/streaming#blurry-or-low-resolution-stream-on-lan)
- [Sign-in & accounts](/troubleshooting/accounts)
- [Auth issues](/troubleshooting/accounts)
- [`tapflow admin init` fails (`Already initialized`)](/troubleshooting/accounts#tapflow-admin-init-fails-already-initialized)
- [Invitation link expired](/troubleshooting/accounts#invitation-link-expired)
- [Password reset link expired](/troubleshooting/accounts#password-reset-link-expired)
---
# https://www.tapflow.dev/troubleshooting/accounts
# Sign-in & accounts
Fixes for creating the admin account, invitation and password reset links, and signing in.
## `tapflow admin init` fails (`Already initialized`)
An admin account already exists on the relay. Sign in and invite teammates from **Settings → Team**.
## Invitation link expired
Invitation links expire after **7 days**. An Admin must create a new invitation from **Settings → Team**. If SMTP is not configured, copy the link shown in the invite dialog and share it manually.
## Password reset link expired
Password reset links expire after **2 hours**. An Admin can make a new link with **Reset pwd** on the member's row in **Settings → Team**. The link appears in a dialog to copy and share, and is also emailed when SMTP is configured. Only the newest link works.
## The invitation says the email is already a member {#invite-already-a-member}
**Already a member. Change their role in the list instead.** appears under the email field, or the invite page says **This email already has an account**. An invitation creates an account and never changes one, so an address that already signs in cannot be invited again, whatever its letter case.
- To give the member another role, change it in the member list in **Settings → Team**.
- If they forgot their password, an Admin presses **Reset pwd** on their row instead.
## Signed out after upgrading {#signed-out-after-upgrading}
After upgrading to v0.26.0, everyone has to sign in once. Sessions are now tied to the password they were signed in with, and sessions from earlier versions carry no such link. The same happens to one user whenever their password is reset or changed: every other browser is signed out. The browser where they changed it in **Settings** stays signed in.
## Tokens stopped working after a password reset {#tokens-revoked-by-reset}
A password reset revokes all of that member's personal access tokens, CI and agent tokens included, and agents connected with them disconnect with `Unauthorized: this token was revoked or has expired, or its owner was removed`. Whoever held a compromised account could otherwise keep the tokens they made. After signing in, issue new tokens in **Settings → Tokens** (a Viewer uses [`POST /api/v1/tokens`](/reference/api#post-api-v1-tokens)), and put them where the old ones were: CI secrets and each remote agent's `--token`. A password change in **Settings** keeps the tokens.
## Two accounts differ only in letter case {#accounts-differ-in-case}
The relay's startup log says **Accounts 2,3 differ only in letter case or spaces**. Email addresses are compared without regard to case now, but two accounts created earlier as, for example, `alice@example.com` and `Alice@example.com` are left as they are. Each still signs in with its exact address. Remove the one nobody uses in **Settings → Team**, and the warning stops.
---
# https://www.tapflow.dev/troubleshooting/android-emulator
# Android emulator
Fixes for Android emulator stream, color and speed problems.
## Stream does not start or encoder crashes
Most often the AVD uses an untested `google_apis_playstore` image. Recreate the AVD with the tested `google_apis/arm64-v8a` image:
```sh
sdkmanager "system-images;android-35;google_apis;arm64-v8a"
avdmanager create avd -n Pixel_8 -k "system-images;android-35;google_apis;arm64-v8a"
```
## Colors look different from the emulator (less saturated)
Colors in tapflow may look slightly less saturated than the Android emulator window. **This is expected — and tapflow is actually the more faithful reference.**
- **tapflow** renders the pixel values from the agent's H.264 stream as-is, so it stays close to your design source (Figma, etc.).
- **The emulator window** runs the image through an extra display color-processing step when drawing to the screen, which boosts saturation above the source.
For reviewing design colors, **tapflow is the more trustworthy reference**.
::: details Measured example
Measuring a flat solid orange swatch with a color picker:
| Source (Figma) | tapflow | Emulator |
|----------------|---------|----------|
| `#FF8000` (G=128) | `#FF7700` (G=119) | `#FF6C00` (G=108) |
tapflow (G=119) stays closer to the source (G=128), while the emulator (G=108) drifts further from it, rendering a more saturated orange.
Black (`#000000`), white (`#FFFFFF`), and pure R/G/B are identical across all three — the difference appears only in midtone saturation, not from a corrupted stream.
:::
## Emulator is slow when the Mac is unattended
tapflow automatically prevents the host Mac from idle-sleeping while the agent is running (`caffeinate -di`, or `caffeinate -i` when `TAPFLOW_ALLOW_DISPLAY_SLEEP` is set). The assertion is acquired when the agent connects and released when it exits.
If the emulator is still slow when the Mac is unattended, check the following.
| Check | Why it matters |
|-------|----------------|
| **Power adapter connected** | Battery mode lowers CPU performance — `caffeinate` does not override this scaling. |
| **Laptop lid is open** | Closing the lid triggers clamshell sleep, which `caffeinate` cannot prevent. |
---
# https://www.tapflow.dev/troubleshooting/builds
# Builds & uploads
Fixes for a build upload that fails, or an uploaded build that lands on the wrong app.
## iOS build upload errors
### `400` error on upload
| Cause | Fix |
|-------|-----|
| `.ipa` file uploaded | `.ipa` is for real devices. Build with `xcodebuild -sdk iphonesimulator` and zip the `.app` folder. |
| `.app` not at the zip root | Extracting the zip must produce `MyApp.app` directly — not inside a subfolder. |
| Device-only slices | Confirm it is a simulator build. `lipo -info MyApp.app/MyApp` must include `x86_64` or `arm64` (simulator). |
## `INSTALL_FAILED_NO_MATCHING_ABIS` — APK not compatible with Apple Silicon emulator
```
INSTALL_FAILED_NO_MATCHING_ABIS: Failed to extract native libraries, res=-113
```
Apple Silicon Macs (M1/M2/M3) run Android Emulator in a native ARM64 environment. An APK must include the `arm64-v8a` ABI to run on it.
Check which ABIs your APK supports:
```sh
aapt dump badging your-app.apk | grep native-code
```
| Result | Compatible |
|--------|-----------|
| `native-code: 'arm64-v8a'` | ✅ |
| `native-code: 'armeabi-v7a' 'arm64-v8a'` | ✅ |
| `native-code: 'armeabi-v7a' 'x86'` | ❌ |
| `native-code: 'x86' 'x86_64'` | ❌ |
If `arm64-v8a` is missing, the app was built targeting 32-bit ARM or Intel emulators only. Ask your development team to add `arm64-v8a` to the ABI split configuration.
::: details ABI reference
| ABI | Architecture | Apple Silicon Emulator |
|-----|-------------|----------------------|
| `arm64-v8a` | 64-bit ARM | ✅ Required |
| `armeabi-v7a` | 32-bit ARM | ❌ |
| `x86_64` | 64-bit Intel | ❌ |
| `x86` | 32-bit Intel | ❌ |
:::
## An APK upload shows as 'Unversioned' or merges into the wrong app
The relay reads an APK's app name, version, and package name with `aapt` from the Android build-tools. Without build-tools it can't read them, so the build is stored with no version or package name.
- An upload that specifies an `app_id` is rejected with `400` in this case, so an unidentifiable build can't be filed under the app you named.
- `tapflow doctor` flags this as a warning on the Android `aapt (build-tools)` check.
Install build-tools on the machine that runs the relay to fix it.
```sh
tapflow setup android
```
If you've already run `tapflow setup`, run it again to add build-tools. To install by hand, use `sdkmanager --sdk_root="$ANDROID_HOME" "build-tools;35.0.0"`.
If build-tools is already installed and a targeted upload still returns `400`, the APK itself is likely corrupt or not a valid package — rebuild or re-export it. `aapt dump badging your-app.apk` should print a `package: name=...` line for a valid APK.
---
# https://www.tapflow.dev/troubleshooting/install-and-agents
# Install & agents
Problems that come up while preparing an agent Mac and connecting its agent to the relay.
## Agent connection issues
### It says an agent is already running {#agent-already-running}
`tapflow agent start` stopping with **AGENT ALREADY RUNNING** means a tapflow agent for that platform
is already running on this Mac.
One agent manages **every** simulator on its machine. Running several simulators and having several
people each test on their own, at the same time, is what one agent does — and it still is. Starting a
second agent is a different thing: it would compete with the first for the same device list and the
same network filter, and the relay treats the two as one agent anyway.
Stop the one that is running, or use the session it already serves.
### Agent cannot connect to the relay
1. Verify the relay is running.
2. Check the scheme of the URL in the `--relay` option: `ws://` for a plain-HTTP relay, `wss://` when the relay has `tls` configured and serves HTTPS. The agent command that `tapflow relay start` prints (and `tapflow start`, when this Mac runs no agent) already carries the right scheme, as does the one in the **Agent** token dialog.
3. Run `tapflow doctor` to inspect your environment. If the relay is running on the same Mac, the `Port 4000` check fails because the relay holds the port. You can ignore that one.
## `tapflow doctor` failures
### All iOS checks fail
The iOS agent only runs on macOS (Apple policy). You cannot start an iOS agent on Linux or Windows.
### `Xcode not found` — Xcode is not installed
Install Xcode from the Mac App Store or the Apple Developer site, then run:
```sh
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
```
### `Xcode not found` — Xcode is installed but `xcode-select` is not configured
This commonly happens after installing Xcode from the Mac App Store. Xcode is present but the developer tools path is not registered. If your Xcode is not at `/Applications/Xcode.app`, for example `Xcode-27.0.0.app` installed by `xcodes`, use the path `tapflow doctor` prints instead:
```sh
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
```
Run `tapflow doctor` again to confirm the check passes.
### No simulator is running
`tapflow doctor` does not pass or fail on whether a simulator is booted. It passes as long as at least one simulator is available, and warns only when there are none. The agent boots simulators on demand when a session starts.
To boot a simulator before starting:
```sh
tapflow devices # list available simulators
tapflow boot "iPhone 16 Pro"
```
### `adb not found`
Android Studio is installed but `adb` is not in `$PATH`. Add the Android SDK `platform-tools` directory to your shell profile:
```sh
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/platform-tools
```
Add these lines to `~/.zshrc` (or `~/.bashrc`) to make the change permanent, then run `source ~/.zshrc`.
## `tapflow init` says `CONFIG KEPT`
The install already has a `tapflow.config.json`, so `init` left it alone and refreshed only the tapflow section of `AGENTS.md`. Pass `--force` to write a fresh configuration, or edit the existing file directly.
## The relay is using a configuration or database you did not expect
`tapflow start` and `tapflow relay start` print the install directory, the configuration file and the data directory they resolved. Commands take the install named by `TAPFLOW_HOME`, then the current directory when it already is an install, and `~/.tapflow` otherwise ([which install a command uses](/operate/configure#which-install-a-command-uses)) — so running from a directory that holds an older install picks that one up. Set `TAPFLOW_HOME` to be explicit.
---
# https://www.tapflow.dev/troubleshooting/ios-simulator
# iOS simulator
Fixes for an iOS simulator that will not open or boot, and for broken Korean input.
## Opening a build fails with `spawn unknown error` {#spawn-unknown-error}
Check the Mac's architecture:
```bash
uname -m # arm64 = Apple Silicon, x86_64 = Intel or Rosetta
sysctl -n sysctl.proc_translated # 1 = this shell runs under Rosetta
```
If `sysctl` prints `1`, the Mac is Apple Silicon and the shell is running under Rosetta: open a Terminal that
is not set to "Open using Rosetta", check that `node -p process.arch` prints `arm64`, and start the agent from
there.
If `uname -m` reports `x86_64` and `sysctl` prints `0` or nothing, this is an **Intel Mac and the agent does not support it**. The native helper
binaries are built for arm64 only, so macOS refuses to exec them (`EBADARCH`), which Node surfaces as
`Unknown system error -86` and the dashboard shows as `spawn unknown error`.
There is no workaround on the agent side today. Run the agent on an Apple Silicon Mac — the relay and
the dashboard have no such constraint, so only the machine driving the simulators has to change.
Intel support is possible and tracked in
[#464](https://github.com/jo-duchan/tapflow/issues/464); it needs a universal build and verification on
hardware the maintainers do not have, so it is not scheduled. See
[Requirements](/operate/requirements#agent).
If `uname -m` says `arm64`, this is a different problem — a helper that is missing or not executable
produces the same message. Check that the agent package installed completely.
## iOS Simulator service version mismatch {#ios-simulator-service-version-mismatch}
After updating Xcode, you may see a macOS alert:
> "Loaded CoreSimulatorService is no longer valid for this process … Service version (X) does not match expected service version (Y)."
tapflow automatically detects this and restarts the service. If the automatic recovery fails (the alert still appears after retrying), run this command manually:
```sh
killall -9 com.apple.CoreSimulator.CoreSimulatorService
```
`launchd` will restart the service immediately. Then re-run `tapflow start`.
::: details Why this happens
Xcode ships a newer `CoreSimulator.framework` but the old `CoreSimulatorService` daemon is still running from the previous session. After the first `xcrun simctl` call notices the version mismatch, tapflow force-kills the daemon so launchd can restart it with the new version. If the daemon is stuck and does not die on the first attempt, the manual `killall -9` above is needed.
:::
## iOS Simulator fails to boot — "cannot be located on disk" {#simulator-data-missing}
When an Xcode or macOS update prunes an old runtime, a simulator can linger in the device list while its data directory is gone from disk. `simctl list` still reports it as available, but booting fails:
> Unable to boot device because it cannot be located on disk. The device's data is no longer present …
tapflow recovers from this automatically — when you open the device in the dashboard, the agent erases the broken simulator to regenerate its data and retries the boot once. A healthy simulator is never erased.
If the automatic recovery does not clear it, remove the stale devices manually. This deletes simulators whose runtime is gone:
```sh
xcrun simctl delete unavailable
```
If one specific simulator still fails, delete it by UDID and let Xcode recreate a fresh one:
```sh
xcrun simctl delete 822F00B0-D9CF-4B78-8EDD-6322974E4079
```
## iOS 17 and earlier — Korean text splits into individual characters
On iOS 17 and earlier simulators, Korean input does not combine into syllables — characters appear separated (e.g., "안녕" → "ㅇㅏㄴㄴㅕㅇ").
This is a bug in the iOS Simulator's IME, not in tapflow. It also reproduces in system apps like Messages.
**Upgrade to an iOS 18+ Simulator Runtime.**
Install it from Xcode → Settings → Platforms.
::: details References
- [React Native #41494](https://github.com/facebook/react-native/issues/41494)
- [Flutter #135825](https://github.com/flutter/flutter/issues/135825)
:::
---
# https://www.tapflow.dev/troubleshooting/streaming
# Stream & sessions
Fixes for a stream that stutters or looks blurry, and for a session that ends by itself.
## Stream lag or stuttering {#stream-lag}
Narrow it to one of three causes — the network between agent and relay, the agent Mac's resources, or display sleep.
### Prefer a wired LAN
The agent streams video frames to the relay continuously, so the link between them sets the baseline smoothness. **Wired Ethernet is recommended** for the relay and agent machines. Wi-Fi works, but it adds latency and jitter — and on the relay Mac it can cause the periodic hitching below.
### Periodic hitching every ~0.5s on Wi-Fi (AWDL)
If the stream stutters in a steady rhythm (roughly twice a second) over Wi-Fi, the likely cause is **AWDL** (Apple Wireless Direct Link), the interface behind AirDrop, AirPlay, Handoff, and Sidecar. It periodically hops Wi-Fi channels, leaving the data channel for ~90 ms each time, which surfaces as a sawtooth latency spike and a visible hitch.
The robust fix is a **wired connection**: over Ethernet the data never rides Wi-Fi, so AWDL is irrelevant.
If you must stay on Wi-Fi, quiet AWDL from System Settings (reversible, no admin needed):
- **AirDrop** → "No One"
- **AirPlay Receiver** → off (System Settings → General → AirDrop & Handoff)
- **Handoff** → off (same pane)
- **Bluetooth** → off
AWDL only hops when something triggers it (AirDrop browsing, AirPlay, Handoff, Bluetooth proximity); with those quiet it stays idle.
To confirm, ping the router at a tight interval from the relay Mac and watch for the sawtooth: run `ping -i 0.01 `, and if the sawtooth disappears on a wired connection, AWDL was the cause.
::: tip Advanced: disabling awdl0 directly
`sudo ifconfig awdl0 down` disables AWDL for the session. It's temporary (reverts on reboot or the next time you use AirDrop) and needs admin, so prefer the toggles above or a wired link.
:::
### Host CPU / RAM pressure
The simulator and the H.264 encoder are the heavy consumers; when the agent Mac is starved (especially under memory pressure), capture and encode fall behind.
- Check CPU and RAM for the affected Mac in the **Mac Resources** tab.
- Run the relay and the agent on **separate Macs** so they don't compete for resources (this also scales agent capacity).
- Reduce the number of simulators running at once on one Mac.
### Display sleep
By default the agent keeps the host display awake while a session is active, because a sleeping display parks the GPU and throttles the simulator. If you set `TAPFLOW_ALLOW_DISPLAY_SLEEP=1`, expect the stream to slow whenever the display turns off. See [Agent Setup](/operate/agents#host-display-and-sleep).
### Blurry or low-resolution stream on LAN
A plain-HTTP LAN connection uses the **Standard** profile, which caps the stream at 1280 px (longest side) so the WASM decoder stays responsive. To stream at the simulator's native resolution, serve the relay over HTTPS — that moves you to the **Smooth** profile (hardware decoding, native resolution). See the [certificate method in Configuring tapflow](/operate/configure#_3-certificate-method-when-smooth-is-chosen). You can also raise the cap without HTTPS by setting `TAPFLOW_MAX_SIZE_LAN` on the agent; see [Streaming Quality](/operate/streaming-quality).
## Session issues
### Session ends automatically
5 minutes after the browser disconnects, the relay asks the agent to shut the device down. While a browser stays connected, nothing times out, even with no input. Change the delay with the relay's `IDLE_TIMEOUT_MS` environment variable (in milliseconds). Reconnect from the dashboard.