# Create an agent Source: https://docs.axilio.ai/agents/create Save a task as instructions or code, add revisions, roll back, and promote a repaired script. An agent's name can contain letters, digits, hyphens and underscores. It must be unique in the organization. ## Create an instructions agent Instructions are a task in plain English. A model works through them on the phone. Add an `output_schema` when you want a structured result. > [tab] Dashboard > > The dashboard makes an agent from a run: > > 1. Open [**Agents**](https://app.axilio.ai/dashboard/axilio/agents) and click **Create agent**. This opens **Run a task**. > 2. Describe the task, for example: "Open Settings, go to About phone, and report the Android version exactly as shown." Then start the run. > 3. When the run has ended, click **Save as agent** on the run. See [Save a run as an agent](#save-a-run-as-an-agent). > > To set a **Result schema**, the **Model** or the limits, open the agent and click **Edit**. > [tab] CLI > > ```bash theme={null} > axilio agents create android-version \ > --instructions "Open Settings, go to About phone, and report the Android version exactly as shown." > ``` > > `--model` picks the model. Leave it out for the platform default. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > agent = client.agents.create( > name="android-version", > instructions="Open Settings, go to About phone, and report the Android version exactly as shown.", > ) > print(agent.id) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > instructions := "Open Settings, go to About phone, and report the Android version exactly as shown." > agent, err := api.Agents.Create(context.Background(), &platformgo.AgentCreateRequest{ > Name: "android-version", > Instructions: &instructions, > }) > if err != nil { > log.Fatal(err) > } > fmt.Println(agent.ID) > } > ``` > [tab] REST API > > REST API References: > > * [**Create an agent**](/api-reference/rest/agents/create-an-agent): `POST /agents` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/agents \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "name": "android-version", > "instructions": "Open Settings, go to About phone, and report the Android version exactly as shown.", > "output_schema": { > "type": "object", > "properties": {"android_version": {"type": "string"}}, > "required": ["android_version"] > } > }' > ``` ## Create a code agent A code agent runs a Python script with no model. The script drives `phone`, reads its inputs from `variables` and reports its result with `run.done`. See [What a run's code can use](/agents/overview#what-a-runs-code-can-use). ```python search.py theme={null} phone.locator(query="the search box").fill(variables.get("query", "axilio")) phone.get_by_text("Search").tap() run.done({"searched": True}) ``` > [tab] Dashboard > > The dashboard makes an agent from a run, so a code agent starts as a > saved run or as an existing agent you change: > > * Save a run's script: on an ended run, click **Save as agent** and choose to save the script. > * Change an existing agent to code: open the agent, click **Edit** and choose **Code**. Paste the script under **Script**, choose what happens **When a step fails** (**Fail the run**, **Ask a person** or **Repair it**), and save. > [tab] CLI > > ```bash theme={null} > axilio agents create search-flow --code search.py > ``` > > `--code` saves the file as revision 1. Leave out both `--code` and > `--instructions` to create an empty agent and push code later. The CLI > caps code files at 256 KiB. > [tab] REST API > > REST API References: > > * [**Create an agent**](/api-reference/rest/agents/create-an-agent): `POST /agents` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > jq -n --rawfile code search.py \ > '{name:"search-flow", code:$code, on_failure:"ask"}' | > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/agents \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data-binary @- > ``` Send `instructions` or `code`, not both. A name already used in the organization returns `409`. An unsupported model or an invalid output schema returns `400`. ## Handle a failed step A code revision's `on_failure` decides what a run does when a step fails, for example when an element can't be found: | `on_failure` | What happens | | - | - | | `fail` (default) | The run fails, with the reason in `error`. | | `ask` | The run pauses with reason `step_check_failed`. A person can fix the screen in the live view, then resume with `retry` to run the script again, or `complete` to end the run with their own result. See [Answer a paused run](/agents/pause-and-follow-up#answer-a-paused-run). | | `repair` | The model takes over in the same run. It finishes the task from the current screen, so the run can still succeed, and proposes a fixed script as a new revision. | A repaired run has `repair.repaired: true` and, when it proposed a fix, `repair.proposed_revision_id`. The proposed revision has `proposal_state: "waiting"` and is not used until a person promotes it. Repair time and model steps count against the run's limits and are billed like any model step. ## Save a run as an agent Save an ad hoc run as an agent once it has ended. With `from: "task"` (the default), the run's task becomes an instructions revision. With `from: "script"`, the code the run executed becomes a code revision. The run's model, output schema and limits fill in any setting you leave out. > [tab] Dashboard > > 1. Open the run from [**Runs**](https://app.axilio.ai/dashboard/axilio/runs). > 2. Click **Save as agent**. It shows on a run that has ended and wasn't started from a saved agent. > 3. Enter a name and choose whether to save the task or the script. > [tab] REST API > > REST API References: > > * [**Create an agent**](/api-reference/rest/agents/create-an-agent): `POST /agents` > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/agents \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"name": "android-version-script", "source_run_id": "run_123", "from": "script"}' > ``` ## Pull and push code Each push adds a revision, which becomes the one runs use. > [tab] Dashboard > > 1. Open the agent from [**Agents**](https://app.axilio.ai/dashboard/axilio/agents) and click **Edit**. > 2. Change the instructions or the script. > 3. Save. The change becomes a new version. > [tab] CLI > > ```bash theme={null} > axilio agents pull agent_123 --out search.py > # Edit search.py. > axilio agents push agent_123 search.py --message "handle sign-in" > ``` > > `pull` prints the code to stdout without `--out`. An agent whose current > revision is instructions has no code to pull. > [tab] REST API > > REST API References: > > * [**Get an agent**](/api-reference/rest/agents/get-an-agent): `GET /agents/{agent_id}` > * [**Get a revision**](/api-reference/rest/agents/get-a-revision): `GET /agents/{agent_id}/revisions/{revision_id}` > * [**Add a revision**](/api-reference/rest/agents/add-a-revision): `POST /agents/{agent_id}/revisions` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > Read the agent's `current_revision_id`, then fetch that revision's `code` > or `instructions`: > > ```bash theme={null} > REVISION_ID=$(curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/agents/agent_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | jq -r .current_revision_id) > > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/agents/agent_123/revisions/$REVISION_ID" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | jq -r .code > search.py > ``` > > Add a revision. `kind` is `code` or `instructions`: > > ```bash theme={null} > jq -n --rawfile code search.py \ > '{kind:"code", code:$code, message:"handle sign-in", on_failure:"repair"}' | > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/agents/agent_123/revisions \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data-binary @- > ``` An agent can switch kind: add an instructions revision to a code agent, or the reverse. Revisions are never changed or removed. ## Roll back or promote a revision A rollback copies an earlier revision forward as the newest one, so history only grows. Promoting makes a repair's proposed fix the current revision. > [tab] Dashboard > > 1. Open the agent, click **Edit**, and open **Version history**. > 2. Select a version to read it. > 3. Click **Restore** to roll back to it, or **Use this fix** on a proposed fix. > [tab] CLI > > ```bash theme={null} > axilio agents revisions agent_123 > axilio agents rollback agent_123 rev_456 > ``` > > The CLI doesn't promote proposed fixes yet. Use the Dashboard or the REST > API. > [tab] REST API > > REST API References: > > * [**List an agent's revisions**](/api-reference/rest/agents/list-an-agents-revisions): `GET /agents/{agent_id}/revisions` > * [**Roll an agent back to an earlier revision**](/api-reference/rest/agents/roll-an-agent-back-to-an-earlier-revision): `POST /agents/{agent_id}:rollback` > * [**Promote a proposed fix**](/api-reference/rest/agents/promote-a-proposed-fix): `POST /agents/{agent_id}/revisions/{revision_id}:promote` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/agents/agent_123/revisions?limit=20" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/agents/agent_123:rollback" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"revision_id": "rev_456"}' > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/agents/agent_123/revisions/rev_789:promote" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` Promoting returns `409` when the revision is not a fix waiting to be promoted, or when the agent has moved on from the revision the repaired run ran. Such a fix reads `proposal_state: "superseded"`. ## Change settings or delete an agent `PATCH /agents/{agent_id}` changes the name, model, output schema, limits and data settings. Fields you leave out keep their values, and `output_schema: null` clears the schema. Instructions and code change only through revisions. Deleting an agent removes it and its revisions, and cancels its schedules' runs that haven't started. Its past runs stay in the run list. > [tab] CLI > > ```bash theme={null} > axilio agents get agent_123 > axilio agents delete agent_123 --yes > ``` > [tab] REST API > > REST API References: > > * [**Update an agent**](/api-reference/rest/agents/update-an-agent): `PATCH /agents/{agent_id}` > * [**Delete an agent**](/api-reference/rest/agents/delete-an-agent): `DELETE /agents/{agent_id}` > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request PATCH \ > --url https://api.axilio.ai/api/v1/agents/agent_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"max_steps": 100, "recording_enabled": true}' > > curl --silent --show-error --fail-with-body \ > --request DELETE \ > --url https://api.axilio.ai/api/v1/agents/agent_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Next steps > [card] [Run the agent](/agents/run) > > Start a run now, later, or many at once. > [card] [Schedule the agent](/agents/schedules) > > Run it on a recurring cron schedule. # Monitor runs Source: https://docs.axilio.ai/agents/monitor List, inspect, stream, cancel and measure runs, and read each run's steps and script. Use the run ID returned when the run started. It is also the run's session ID. Phone and session details fill in once the run gets a phone; the ID doesn't change. In the SDKs (Python 0.23.0 or Go v0.16.0 or later), these endpoints are on `client.runs` in Python and `client.Runs` in Go. ## List and inspect runs > [tab] Dashboard > > Open [**Runs**](https://app.axilio.ai/dashboard/axilio/runs) and filter the > list. Open a run for its phone, its chat of steps, its timeline, its cost > and its recording. > [tab] CLI > > ```bash theme={null} > axilio runs list --agent agent_123 --status failed > axilio runs list --mode agent > axilio runs get run_123 > ``` > > `runs list` also filters by `--schedule`. Add `-o json` for the complete > machine-readable result. > [tab] REST API > > REST API References: > > * [**List runs**](/api-reference/rest/runs/list-runs): `GET /runs` > * [**Get a run**](/api-reference/rest/runs/get-a-run): `GET /runs/{run_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/runs?agent_id=agent_123&status=failed&limit=20" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/runs/run_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` `GET /runs` filters by `agent_id`, `schedule_id`, `mode`, `status`, `trigger` and `device_class`, and searches run IDs with `search`. `status` and `trigger` repeat to match several values. ## Read how a run ended A run that started can still fail later, while it waits for a phone or while it runs. Fetching a failed run returns HTTP `200`, so branch on `status`: | Field | What it holds | | - | - | | `status` | `scheduled`, `queued`, `running`, `paused`, `completed`, `failed` or `cancelled`. | | `output` | A completed run's result, which matches its `output_schema`. | | `result_text` | A short account of the result. | | `error` | Why the run failed. | | `usage` | The model steps, tokens and cost of the run's model calls, across its turns. | | `repair` | On a code run, whether the model took over and the fix it proposed. | ```bash theme={null} curl --silent --show-error --fail-with-body \ --url https://api.axilio.ai/api/v1/runs/run_123 \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | jq '{status, output, result_text, error}' ``` ## Stream a run Every run has a `stream_url`: its live events as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). The URL carries a short-lived read-only token, so a browser can open it as is. A server can send the API key header instead: ```bash theme={null} curl --no-buffer --silent --show-error --fail-with-body \ --url https://api.axilio.ai/api/v1/realtime/runs/run_123/events \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" ``` Each event's name is the frame kind (`span` or `log`), its data is the [frame](/telemetry/frames) as JSON, and its `id` is a resume cursor. The stream carries the run's spans, the model's text as it streams (`model_delta` logs), and the run's own events, all logs: | `log_type` | When | | - | - | | `run_status` | The run starts on its phone or goes back to running. The last event of the stream is the run's final `run_status`. | | `run_input_required` | The run paused. The body is the question, and the attributes carry the input request's ID, reason and `expires_at`. | | `run_resumed` | A person answered. The body is the action, never the answer. | | `run_result` | How the run ended on its phone, with the result or the error. | | `user_message` | A message you sent with `:sendMessage` reached the run. | The stream ends when the run ends. To resume after a dropped connection, send the last event's `id` as the `Last-Event-ID` header. The run's own events and the model's text are kept for 10 minutes, so a reader that comes back later gets the run's retained spans and its final `run_status`. Dedupe on the span ID and phase across reconnects. With Telemetry off, the stream still carries the run's own events and the model's text, live only. To poll instead of holding a connection, page through the same events with `GET /runs/{run_id}/events?cursor=...`. A live run's page always has a `next_cursor`; an ended run's last page closes with its final `run_status`. The CLI follows a run to its end with `runs watch`: ```bash theme={null} axilio runs watch run_123 axilio runs watch run_123 -o json ``` The watch ends when the run does, even while the run's phone is still held for a follow-up. If the run pauses for a person, the watch prints the question and keeps following. Answer it in the Dashboard. In JSON mode, the output is one JSON object per line. A pause adds a line with `"input_required": true` and the run's `input_request`. The last line is a summary with the run's status, `error`, `result_text` and `output`: ```json theme={null} {"error":"the Search button never appeared","output":null,"result_text":null,"run_id":"run_123","status":"failed","watch_end":true} ``` `runs watch` exits with 0 when the run completed, 1 when it failed, and 7 when it was cancelled or you stopped watching. A cancelled run's error includes its reason when it has one. See [Live Telemetry](/telemetry/live). ## Read the run's steps `GET /runs/{run_id}/steps` lists an agent-mode run's model steps, oldest first: what each step sent the model, the model's answer and tool calls, links to the step's screenshots, and its tokens and cost. Page with `cursor` and `next_cursor`. The screenshot links are short-lived, so read the steps again for fresh links. ```bash theme={null} curl --silent --show-error --fail-with-body \ --url "https://api.axilio.ai/api/v1/runs/run_123/steps?limit=20" \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" ``` The list is empty once the run's transcript has passed your plan's retention, or when your organization keeps none. ## Read the run's script `GET /runs/{run_id}/script` returns every cell the run executed, in order: as one Python file in `script`, and one by one in `cells`, each with its `status` (`ok`, `error` or `running`). Failed and exploratory cells are included. A code run's first cell is its revision's script. ```bash theme={null} curl --silent --show-error --fail-with-body \ --url https://api.axilio.ai/api/v1/runs/run_123/script \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | jq -r .script ``` The script is kept with the run's data, so it outlives the step transcript. `partial: true` means some cells may be missing. To turn a good run into a code agent, [save the run as an agent](/agents/create#save-a-run-as-an-agent) with `from: "script"`. ## Cancel a run | Run status | What cancelling does | | - | - | | `scheduled` or `queued` | The run never starts. | | `running` or `paused`, agent mode | The run stops, then holds its phone for `keep_alive_seconds` so you can still follow up. A shared phone bills for that hold. See [Cancel and the hold](/agents/pause-and-follow-up#cancel-and-the-hold). | | `running` or `paused`, code mode | The run stops and releases its phone. | | `completed`, `failed` or `cancelled` | Nothing. The call returns `404`. | > [tab] Dashboard > > Open the run from [**Runs**](https://app.axilio.ai/dashboard/axilio/runs) > and cancel or stop it from its page. > [tab] CLI > > ```bash theme={null} > axilio runs cancel run_123 --yes > ``` > [tab] REST API > > REST API References: > > * [**Cancel a run**](/api-reference/rest/runs/cancel-a-run): `POST /runs/{run_id}:cancel` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/runs/run_123:cancel" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` To release a held phone right away, end the run's session. See [Release the phone early](/agents/pause-and-follow-up#release-the-phone-early). ## Measure runs `GET /runs/metrics` returns your finished runs over a window as points, one per hour or day, on the shared [metrics contract](/api-reference/api-changes-2026-10#metrics). Each point has `runs`, `completed_runs`, `failed_runs`, `cancelled_runs`, `success_rate`, `duration_p50_ms`, `duration_p95_ms`, `total_cost_microdollars` and `model_steps`. Split the points with `group_by`: `agent`, `revision`, `status`, `trigger`, `mode`, `schedule` or `device_class`. > [tab] CLI > > ```bash theme={null} > axilio runs metrics > axilio runs metrics --agent agent_123 --from 2026-10-01 --to 2026-10-27 > ``` > > The table has one row per agent. Its success rate is completed runs over > completed and failed runs. > [tab] REST API > > REST API References: > > * [**Get run metrics**](/api-reference/rest/runs/get-run-metrics): `GET /runs/metrics` > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/runs/metrics?group_by=agent&granularity=day&time_zone=America%2FLos_Angeles" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` * A run counts in the bucket it finished in. Runs still going are not counted. * A point's `success_rate` is completed runs over finished runs, from 0 to 1. * A run's cost is its phone time plus the inference made under it. Phone time is billed when the session ends, so a run's cost can land after the run does. ## Next steps > [card] [Pause and follow up](/agents/pause-and-follow-up) > > Answer a paused run and send follow-ups. > [card] [Webhooks](/webhooks/events#runs) > > Get each run's lifecycle events pushed to you. # Agents and runs Source: https://docs.axilio.ai/agents/overview Hand a phone a task in plain English or as Python, save it as an agent, and run it from the API, the CLI, the Dashboard or a schedule. An **agent** is a task for a phone that you save to run again. Each agent has numbered **revisions**, and each revision is one of two kinds: * **Instructions**: a task in plain English, such as "Open Settings and report the Android version". A model works through it on the phone, one step at a time, until it can report a result. * **Code**: a Python script that drives the phone with the Axilio SDK. It runs as written, with no model, unless a failed step hands it to the model to repair. A **run** is one execution on a phone. A run starts from a saved agent or from an ad hoc `task` with nothing saved. Its run ID is also the ID of the phone session it runs in. ## Run modes Every run has a `mode`: | Mode | What drives the phone | Where it comes from | | - | - | - | | `agent` | A model works through the task. It writes and runs Python against the SDK, looks at the screen after each step, and calls `run.done` with the result. | An ad hoc `task`, or an agent whose current revision is instructions. | | `code` | Your script runs. | An agent whose current revision is code. | Both modes run the same SDK on the same phones, so the script an agent-mode run leaves behind can be saved as a code revision. See [Read the run's script](/agents/monitor#read-the-runs-script). ## Lifecycle 1. Start a run with `POST /runs`, the CLI, the Dashboard or a [schedule](/agents/schedules). A run booked with `start_at` is `scheduled` until then. 2. The run is `queued` until a phone is free, then `running`. 3. A run can be `paused` to ask a person for input, such as a one-time code or an approval. See [Pause and follow up](/agents/pause-and-follow-up). 4. The run ends `completed`, `failed` or `cancelled`. A completed run carries `output` (which matches the run's `output_schema`, if it has one) and `result_text`, a short account of the result. A failed run carries `error`. 5. After an agent-mode run ends, it holds its phone for `keep_alive_seconds` (120 seconds by default on `POST /runs`). A message sent with `:sendMessage` in that time starts the run's next **turn** on the same phone. After the hold, the phone is released and you start a new run. ## What a run's code can use Instructions runs write their own code. Code revisions use the same names: | Name | What it is | | - | - | | `phone` | The run's phone, as the SDK's driver. `driver` is the same object, so code written for a session runs unchanged. | | `variables` | A dict of the run's inputs, from the run's `variables`. | | `run.done(output, result_text=None)` | Records the run's result. `output` must be JSON and must match the run's `output_schema`, if it has one. | | `run.ask(message, response_schema=None, reason="approval_required")` | Pauses the run for a person and returns their answer. `reason` is `one_time_code_required`, `approval_required`, `choice_required` or `challenge_blocked`. | ```python theme={null} phone.get_by_text("Settings").tap() phone.get_by_text("About phone").tap() version = phone.locator(query="the Android version number").text() run.done({"android_version": version}, result_text=f"Android {version}") ``` > [!WARNING] > > Never ask a person for a password with `run.ask`. An answer joins the run's > record, and in an agent-mode run it is also sent to the model. ## Agent settings An agent's settings apply to each of its runs. An ad hoc run takes the same settings on `POST /runs`. | Setting | Default | What it does | | - | - | - | | `model` | The platform default | The model an instructions run calls. `GET /models` lists the models you can use and their prices. | | `output_schema` | None | A JSON Schema the run's `output` must match. Leave it out to accept any result. | | `max_steps` | 500 | The most model steps a run may take, from 1 to 500. | | `max_cost_microdollars` | 10,000,000 (\$10) | The most a run's model steps may cost. | | `timeout_seconds` | 3600 | The longest a run may take, at most one hour. | | `pause_timeout_seconds` | 600 | The longest a paused run waits for an answer, from 60 to 3600 seconds. | | `recording_enabled` | `false` | Records the phone's screen. See [Recordings](/observability/recordings). | | `telemetry_enabled` | `true` | Produces [live and retained Telemetry](/telemetry/data-controls). | | `capture_enabled` | `true` | Saves media the run produces on the phone to your [Files](/files/overview) library. | | `accessibility_enabled` | `false` | Runs in accessibility mode, which needs a phone that supports it. | A code revision also sets `on_failure`, which decides what happens when a step fails. See [Handle a failed step](/agents/create#handle-a-failed-step). ## Limits and billing * A run, its pauses included, fits inside the one-hour session limit. * An agent-mode run can't reach the public internet by default. Set `network: "internet"` on the run if its code needs it. Code runs always have internet access. * Model steps are billed as inference with the `agent_chat` endpoint. See [Usage](/usage/overview). * A shared phone bills phone time for as long as the run's session lasts, including a paused run and the hold after a turn. A dedicated phone bills nothing per session. * Your plan's concurrency limit counts running and paused runs. While you are at the limit, `POST /runs` returns `409`. ## Next steps > [card] [Create an agent](/agents/create) > > Save instructions or code and manage revisions. > [card] [Run a task or an agent](/agents/run) > > Start ad hoc and saved runs on shared or dedicated phones. > [card] [Pause and follow up](/agents/pause-and-follow-up) > > Answer a paused run and continue a run with more turns. > [card] [Monitor runs](/agents/monitor) > > Stream, inspect, cancel and measure runs. # Pause and follow up Source: https://docs.axilio.ai/agents/pause-and-follow-up Pause a run for a person, answer it, message an agent while it works, and continue a finished run on the same phone. A run can stop to ask a person something, take messages while it works, and continue with more turns after it ends. The Dashboard's run page does all of this from one chat. The API endpoints are below. In the SDKs (Python 0.23.0 or Go v0.16.0 or later), these endpoints are on `client.runs` in Python and `client.Runs` in Go. ## Why a run pauses A run reads status `paused` when it waits on a person. Three things pause a run: * The agent asks, for example for a one-time code (`requested_by: "agent"`). * A script calls `run.ask`, or a step fails under `on_failure: "ask"` (`requested_by: "code"`). * You call `POST /runs/{run_id}:pause` (`requested_by: "caller"`). While paused, the run keeps its phone and makes no model calls. Its `input_request` says what it needs: ```json theme={null} "input_request": { "id": "0199a3c2-58c1-7f3a-9f2e-2f9a6a2f6a44", "requested_by": "agent", "reason": "one_time_code_required", "message": "Enter the 6-digit code sent to the phone", "response_schema": {"type": "string", "pattern": "^[0-9]{6}$"}, "created_at": "2026-10-27T17:02:11Z", "expires_at": "2026-10-27T17:12:11Z" } ``` | `reason` | Meaning | | - | - | | `one_time_code_required` | The run needs a code, such as one sent by text or email. | | `approval_required` | The run wants a yes or no before it goes on. | | `choice_required` | The run needs you to pick between options. | | `challenge_blocked` | A check the run can't pass on its own, such as a captcha, blocks it. Solve it in the live view, then answer. | | `step_check_failed` | A step of a code run failed under `on_failure: "ask"`. | | `caller_requested` | You paused the run through the API or the Dashboard. | `expires_at` is the earliest of the run's pause timeout (`pause_timeout_seconds`, 600 by default), its time limit, and the end of its session. If nobody answers by then, the run fails with `input_timeout`. A `run.paused` webhook fires when the run pauses. It carries the reason, never the message. ## Pause a run `POST /runs/{run_id}:pause` asks a running run to pause, with reason `caller_requested`. An agent-mode run pauses at its next model step: a step already running finishes first. A code run pauses at its script's next phone action. The call returns the run as it is, and the run reads `paused` once the pause lands. A run that is not running returns `409`. > [tab] Dashboard > > Open the run and click **Pause the run** next to the message box. Click > **Resume the run** to go on. > [tab] REST API > > REST API References: > > * [**Pause a run**](/api-reference/rest/runs/pause-a-run): `POST /runs/{run_id}:pause` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/runs/run_123:pause" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Answer a paused run `POST /runs/{run_id}:resume` names the `input_request_id` it answers and an `action`: | `action` | What it does | | - | - | | `answer` (default) | Hands the run your `response`, which must match the request's `response_schema`. Add a `message` for a note to the agent. | | `retry` | Runs a code run's script again, from where the phone is now. Only for `step_check_failed`. | | `complete` | Ends the run as completed with your `output`, which must match the run's output schema, and an optional `result_text`. | > [tab] Dashboard > > Open the run. The question shows in the chat. Type the answer and send it. > For a failed script step, you can take control of the phone in the live > view first, then retry the script or complete the run with your own > result. > [tab] REST API > > REST API References: > > * [**Resume a paused run**](/api-reference/rest/runs/resume-a-paused-run): `POST /runs/{run_id}:resume` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/runs/run_123:resume" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "input_request_id": "0199a3c2-58c1-7f3a-9f2e-2f9a6a2f6a44", > "response": "481516" > }' > ``` > > To retry a failed script step, or to finish the run yourself, send one of > these bodies instead: > > ```json theme={null} > {"input_request_id": "0199a3c2-58c1-7f3a-9f2e-2f9a6a2f6a44", "action": "retry"} > ``` > > ```json theme={null} > { > "input_request_id": "0199a3c2-58c1-7f3a-9f2e-2f9a6a2f6a44", > "action": "complete", > "output": {"android_version": "16"}, > "result_text": "Read the version from the screen myself." > } > ``` A response or output that doesn't match its schema returns `422`. A run that is not paused, a different `input_request_id`, or an expired request returns `409`. With `answer` or `retry`, the run reads `running` again once its phone has the answer, and `run.resumed` fires. > [!WARNING] > > Never send a password as an answer. An answer joins the run's record, and in > an agent-mode run it is also sent to the model. If the run needs a sign-in, > sign in on the phone yourself from the live view, then answer. ## Message an agent while it works `POST /runs/{run_id}:sendMessage` sends a message to an agent-mode run that is running or paused. The model reads it before its next step, as a message from the person running the task. Use it to steer the run, for example "Use the work account". A message never resumes a paused run: answer the pause with `:resume`. ```bash theme={null} curl --silent --show-error --fail-with-body \ --request POST \ --url "https://api.axilio.ai/api/v1/runs/run_123:sendMessage" \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data '{"message": "Use the work account, not the personal one."}' ``` * A run holds at most 20 unread messages. A message past that, or one that arrives as the run finishes, may never be read. * A message is required and can be up to 64 KiB. An empty or larger message returns `400`. * The run's stream carries each message the run receives as a `user_message` event. * A code run, or a run that is queued or scheduled, returns `409`. ## Follow up after a turn A task and its follow-ups are one run. When an agent-mode run's turn ends (completed, failed or cancelled), the run holds its phone until `keep_alive_expires_at`, set from the run's `keep_alive_seconds` (120 seconds by default on `POST /runs`). A `:sendMessage` in that time starts the run's next **turn** on the same phone, with the same screen and the same Python state. The run goes back to `queued`, then `running`. ```bash theme={null} # Turn 1 curl --silent --show-error --fail-with-body \ --request POST \ --url https://api.axilio.ai/api/v1/runs \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data '{"task": "Open Settings, then Wi-Fi, and report whether Wi-Fi is on.", "keep_alive_seconds": 300}' # Turn 2, before keep_alive_expires_at curl --silent --show-error --fail-with-body \ --request POST \ --url "https://api.axilio.ai/api/v1/runs/run_123:sendMessage" \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data '{"message": "Now list the saved networks."}' ``` * `turn` is the current turn's number, from 1. `turns` lists every turn, oldest first, each with its opening `message`, `status`, `result_text`, `output`, `error`, `model` and `usage`. Turn 1 has no message: it runs the task. * The run's top-level `status`, `result_text`, `output`, `error`, `started_at` and `completed_at` are the latest turn's. `usage` is the sum of every turn. * Each turn gets the run's full limits: `max_steps`, `max_cost_microdollars` and `timeout_seconds`. A follow-up can set `model` for its turn and nothing else. * After the hold ends, `:sendMessage` returns `409` and the chat is over. Start a new run, which may get a different phone. * The model sees a summary of the earlier turns and continues from the screen as the last turn left it. > [tab] Dashboard > > Open the run. While the phone is held, the phone stays live and the message > box stays open, so you can type a follow-up. After the hold, the page > reads "This chat has ended. Run again to start a new run." > [tab] REST API > > REST API References: > > * [**Send a message to a run**](/api-reference/rest/runs/send-a-message-to-a-run): `POST /runs/{run_id}:sendMessage` > * [**Get a run**](/api-reference/rest/runs/get-a-run): `GET /runs/{run_id}` Each turn fires `run.started` and then `run.completed`, `run.failed` or `run.cancelled` again, with `turn` in the payload. See [Webhook events](/webhooks/events#runs). ## Release the phone early A held phone stays yours until the hold ends. A shared phone bills for that time. To release it sooner, end the run's session. The run's `session_id` is its run ID. > [tab] CLI > > ```bash theme={null} > axilio sessions stop run_123 --yes > ``` > [tab] REST API > > REST API References: > > * [**Release a phone**](/api-reference/rest/phones/release-a-phone): `POST /phones/{phone_id}:deallocate` > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/phones/ph_123:deallocate" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"session_id": "run_123"}' > ``` > > With `session_id`, only that session ends. If the phone has already moved > on, nothing is released and the call still succeeds. To skip the hold entirely, start the run with `keep_alive_seconds: 0`. On a dedicated phone, a session you start on the phone, or another run pinned to it, also ends the hold. ## Cancel and the hold Cancelling a running or paused agent-mode run stops it and then holds the phone for `keep_alive_seconds`, as when a turn ends, so you can still follow up with `:sendMessage`. A shared phone bills for that hold. Release the phone as above, or set `keep_alive_seconds: 0`, if you don't want it. A code run releases its phone when cancelled. See [Cancel a run](/agents/monitor#cancel-a-run). ## Next steps > [card] [Monitor runs](/agents/monitor) > > Stream a run's steps and read its script. > [card] [Webhook events](/webhooks/events) > > Get run.paused, run.resumed and each turn's events. # Run a task or an agent Source: https://docs.axilio.ai/agents/run Start an ad hoc task or a saved agent on a shared or dedicated phone, now, later, or many at once. `POST /runs` starts one run. Send a `task` for an ad hoc run with nothing saved, or an `agent_id` to run a saved agent's current revision with its settings. The response is the run, with its `id`, `status` and `stream_url`. The SDKs need Python 0.23.0 or Go v0.16.0 or later. ## Run an ad hoc task An ad hoc run takes its settings in the request: `model`, `output_schema`, `max_steps`, `max_cost_microdollars`, `timeout_seconds`, `pause_timeout_seconds`, `recording_enabled` and `telemetry_enabled`. Leave them out for the [defaults](/agents/overview#agent-settings). > [tab] Dashboard > > 1. Open [**Run a task**](https://app.axilio.ai/dashboard/axilio/agents/run). > 2. Describe the task, for example "Open Settings and report the Android version". > 3. Optionally, use the options under the box to pick the phone, a result schema, the model and the limits. > 4. Press **Enter**. The run's page opens with the phone and the agent's steps. > [tab] CLI > > ```bash theme={null} > axilio runs start --task "Open Settings, go to About phone, and report the Android version." --watch > ``` > > `--task` runs the instruction without saving an agent. Pass `--task` or > `--agent`, not both. The run takes one of your dedicated phones, whichever > is free, and `--phone-id` pins a specific one. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > run = client.runs.start( > task="Open Settings, go to About phone, and report the Android version.", > targets=[{"device_class": "phone", "ownership": "dedicated"}], > ) > print(run.id) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > task := "Open Settings, go to About phone, and report the Android version." > ownership := platformgo.RunTargetOwnershipDedicated > run, err := api.Runs.Start(context.Background(), &platformgo.AgentRunCreateRequest{ > Task: &task, > Targets: []*platformgo.RunTarget{{ > DeviceClass: platformgo.RunTargetDeviceClassPhone, > Ownership: &ownership, > }}, > }) > if err != nil { > log.Fatal(err) > } > fmt.Println(run.ID) > } > ``` > [tab] REST API > > REST API References: > > * [**Start a run**](/api-reference/rest/runs/start-a-run): `POST /runs` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/runs \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "task": "Open Settings, go to About phone, and report the Android version.", > "output_schema": { > "type": "object", > "properties": {"android_version": {"type": "string"}}, > "required": ["android_version"] > }, > "max_cost_microdollars": 2000000 > }' > ``` ## Run a saved agent A saved agent's run uses the agent's current revision, model, output schema, limits and data settings. > [tab] Dashboard > > 1. Open [**Agents**](https://app.axilio.ai/dashboard/axilio/agents) and select the agent. > 2. Click **Run**. > 3. Optionally, choose the phone and, for an instructions agent, **Internet**: **Phone only (default)** or **Phone and the internet**. Then start the run. > [tab] CLI > > ```bash theme={null} > axilio runs start --agent agent_123 --variables '{"query": "coffee"}' --watch > ``` > > `--watch` follows the run to its end, like `axilio runs watch`. The run > takes one of your dedicated phones, whichever is free. `--phone-id` pins a > specific one, and `--start-timeout` sets how long it may wait. > [tab] REST API > > REST API References: > > * [**Start a run**](/api-reference/rest/runs/start-a-run): `POST /runs` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/runs \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "agent_id": "agent_123", > "variables": {"query": "coffee"}, > "start_timeout_seconds": 300 > }' > ``` `variables` is a JSON object. The run's code reads it as the `variables` dict, in both code and agent-mode runs. `start_timeout_seconds` is how long a queued run may wait for a phone before Axilio cancels it, from 60 to 86,400 seconds. The default is 300. > [!WARNING] > > Axilio stores and delivers run variables as plaintext. Do not put passwords, > API keys, or other secrets in `variables`. Axilio keeps a finished run's `variables` for your plan's data retention window, 30 days unless your plan or contract sets another, then deletes them. After that, the run still reads back with its status and timings but without `variables`. ## Choose the phone Leave out `targets` to run on a shared phone. To run on one of your dedicated phones, send one target: ```json theme={null} "targets": [{"device_class": "phone", "phone_id": "ph_123"}] ``` | Target | Phone | | - | - | | No `targets` | A phone from the shared pool, billed per second. The CLI always sends a dedicated target instead. | | `{"device_class": "phone", "ownership": "dedicated"}` | Any free dedicated phone of your organization. | | `{"device_class": "phone", "phone_id": "ph_123"}` | That dedicated phone. It must be active, or the request returns `400`. If it is busy, the run waits for it, up to its start timeout. | | `"capabilities": {"accessibility": true}` | Adds accessibility mode, which needs a phone that supports it. | A run takes at most one target. ## Give the run internet access An agent-mode run's code reaches only the phone by default (`network: "restricted"`). Set `network: "internet"` when its code needs the public internet, for example to download a file. Code runs always have internet access, so a code agent's run takes no `network`. ## Keep the phone for follow-ups After an agent-mode run's turn ends, the run holds its phone for `keep_alive_seconds`, so you can send a follow-up on the same screen. The default is 120 seconds on `POST /runs` and 0 in a batch. The range is 0 to 3600, and 0 releases the phone as soon as the turn ends. A shared phone bills for the hold. Set `keep_alive_seconds: 0` when nobody will follow up. See [Pause and follow up](/agents/pause-and-follow-up). ## Book a run for later Set `start_at` to start the run at a later time instead of now. For recurring runs, use a [schedule](/agents/schedules). ```bash theme={null} curl --silent --show-error --fail-with-body \ --request POST \ --url https://api.axilio.ai/api/v1/runs \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data '{"agent_id": "agent_123", "start_at": "2026-11-01T03:00:00Z"}' ``` * `start_at` is an RFC 3339 timestamp from a minute to 30 days ahead. * A booked run reads status `scheduled` and trigger `scheduled` until it starts. Filter for it with `status=scheduled`. * A run starts within about 15 seconds of `start_at`, then waits for a phone like any queued run. `start_timeout_seconds` counts from then. * A booked run holds no concurrency slot while it waits. * You can [cancel](/agents/monitor#cancel-a-run) a booked run any time before it starts. It then never runs, and a `run.cancelled` webhook is sent. ## Start many runs `POST /runs:batchCreate` takes up to 1,000 requests, each shaped like a `POST /runs` body. All the runs are created, or none is, and an error names the request it is for. > [tab] REST API > > REST API References: > > * [**Start several runs**](/api-reference/rest/runs/start-several-runs): `POST /runs:batchCreate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/runs:batchCreate" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "requests": [ > {"agent_id": "agent_123", "targets": [{"device_class": "phone", "phone_id": "ph_123"}]}, > {"agent_id": "agent_123", "targets": [{"device_class": "phone", "phone_id": "ph_456"}]}, > {"agent_id": "agent_123", "variables": {"query": "tea"}} > ] > }' > ``` > > The response lists the runs in request order. A batch can't name the same dedicated phone twice. ## Concurrency and balance Your plan's concurrency limit counts running and paused runs. While your organization is at the limit, `POST /runs` and `POST /runs:batchCreate` return `409`. An accepted batch's runs beyond the limit wait in the queue for a free slot. Starting a run with a balance of zero or less returns `402`. See [Errors](/reference/errors#low-balance). ## Next steps > [card] [Monitor runs](/agents/monitor) > > Stream, inspect, cancel and measure the runs. > [card] [Pause and follow up](/agents/pause-and-follow-up) > > Answer a paused run and send follow-ups. # Schedule an agent Source: https://docs.axilio.ai/agents/schedules Run a saved agent on a recurring cron schedule in your time zone. A schedule starts a run of an agent at each occurrence of a cron expression. Its runs have trigger `scheduled` and carry the schedule's `schedule_id`. For a single run at a later time, set `start_at` on [`POST /runs`](/agents/run#book-a-run-for-later) instead. In the SDKs (Python 0.23.0 or Go v0.16.0 or later), these endpoints are on `client.agents` in Python and `client.Agents` in Go. ## Create a schedule > [tab] Dashboard > > 1. Open [**Agents**](https://app.axilio.ai/dashboard/axilio/agents) and select the agent. > 2. Click **Schedule**, then **Add schedule**. > 3. Pick **When** it runs, such as **Every weekday at 9:00 AM**, or choose **Custom** and enter a cron expression. > 4. Pick the **Time zone**. > 5. Choose what happens **If the last run is still going**: **Skip this time** or **Start another anyway**. > 6. Click **Add schedule**. When you edit a schedule later, the button reads **Save schedule**. > [tab] REST API > > REST API References: > > * [**Create a schedule**](/api-reference/rest/agents/create-a-schedule): `POST /agents/{agent_id}/schedules` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/agents/agent_123/schedules \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "name": "weekday morning", > "cron_expression": "0 9 * * 1-5", > "time_zone": "America/Los_Angeles", > "overlap_policy": "skip", > "run_template": { > "variables": {"query": "coffee"}, > "targets": [{"device_class": "phone", "phone_id": "ph_123"}] > } > }' > ``` | Field | What it does | | - | - | | `cron_expression` | Required. Five fields: minute, hour, day of month, month, day of week. `0 9 * * 1-5` is 09:00 on weekdays. | | `time_zone` | Required. The IANA time zone the expression is read in, such as `America/Los_Angeles` or `UTC`. 09:00 stays 09:00 local time across daylight saving changes. | | `overlap_policy` | `skip` (default) drops an occurrence while the schedule's previous run is scheduled, queued, running or paused. `queue` starts it anyway, and it waits for a phone like any run. | | `run_template` | What each run starts with: `variables`, `targets`, `network` (instructions agents only) and `revision_id`. Leave out `revision_id` to run the agent's current revision at each occurrence. | | `start_at`, `end_at` | Optional. No occurrence before `start_at`, or at or after `end_at`. Once `end_at` passes, the schedule ends. | | `name` | An optional label. | A cron expression or time zone that doesn't parse, an expression that never fires, or an agent with no revision returns `400`. ## How occurrences run * A run starts up to about 30 seconds after its occurrence, then waits for a phone like any queued run. * Each occurrence starts at most one run. * On a day when daylight saving skips a time, a schedule for that time doesn't run. On a day that repeats a time, it runs once. * After an outage, only the latest missed occurrence runs. * A scheduled run holds no phone after it ends, so it has no follow-up window. * When an occurrence starts no run, a `schedule.occurrence_skipped` webhook fires with a `reason`: `overlap` (the previous run hadn't finished), `agents_unavailable` (Agents isn't available to your organization) or `model_unavailable` (the agent's model can no longer be used). The schedule goes on. ## Pause, resume, run now, change or delete | Action | Endpoint | Effect | | - | - | - | | Pause | `POST /agents/{agent_id}/schedules/{schedule_id}:pause` | Starts no runs until resumed, and cancels its runs that haven't started. Fires `schedule.paused`. | | Resume | `POST /agents/{agent_id}/schedules/{schedule_id}:resume` | Counts from now. Occurrences missed while paused don't run. An ended schedule stays ended. | | Run now | `POST /agents/{agent_id}/schedules/{schedule_id}:run` | Starts a run from the template now, whatever the schedule's state or overlap policy. Its trigger is `manual` or `api`, not `scheduled`. | | Change | `PATCH /agents/{agent_id}/schedules/{schedule_id}` | Changes the fields you send. `run_template` is replaced whole. `null` clears `start_at` or `end_at`. | | Delete | `DELETE /agents/{agent_id}/schedules/{schedule_id}` | Removes the schedule and cancels its runs that haven't started. Runs it already started keep going. | In the Dashboard, open the agent and click **Schedule** to turn a schedule on or off, edit it, or delete it. A schedule's `state` is `active`, `paused` or `ended`. `next_run_at` is when the next occurrence starts a run, and `last_run_id` is the run it last started. A schedule that reaches its `end_at`, or has no occurrence left, ends and fires `schedule.ended` once. List a schedule's runs with `GET /runs?schedule_id=...` or `axilio runs list --schedule `. ## Next steps > [card] [Monitor runs](/agents/monitor) > > Inspect and measure the runs a schedule starts. > [card] [Webhook events](/webhooks/events#schedules) > > Get schedule.paused, schedule.ended and skipped occurrences. # API changes, October 2026 Source: https://docs.axilio.ai/api-reference/api-changes-2026-10 Workflows become agents and the run's error_message becomes error in the Agents release, and every rename in API 0.126.0, with what to use instead. Two October releases change the API in ways that break existing clients: * The [Agents release](#agents-release) replaces workflows with agents and renames the run's `error_message` to `error`. * [API 0.126.0](#naming-rules) renames parameters, fields, paths and webhook fields to follow one set of naming rules. ## Agents release The Agents release replaces workflows with [agents](/agents/overview). An agent's revision is either code, like a workflow's, or instructions: a task in plain English that a model works through on the phone. Runs can now start from an ad hoc task, pause for a person, take follow-ups on the same phone, and run on a schedule. Workflows are not carried over: recreate each one as a code agent. The old routes and names stop working in this release. There is no compatibility period, so update your code and clients before you upgrade. ### Upgrade to CLI 0.14 CLI 0.14.0 follows this release. See [Upgrade from an earlier CLI](/cli#upgrade-from-an-earlier-cli) for its breaking changes. ```bash theme={null} brew upgrade axilio ``` ### Upgrade the SDKs Python SDK 0.23.0 and Go SDK v0.16.0 follow this release, and earlier versions can't call the removed paths. In Python, `client.workflows` is now `client.agents`, runs start with `client.runs.start()`, and `client.session(workflow_id=...)` is gone. ```bash theme={null} pip install --upgrade axilio go get github.com/axilioai/platform-go@v0.16.0 ``` ### Actions on text that matches several elements When a locator reads the screen (accessibility off, or `strategy="vision"`), an action on text that matches several elements now raises `ActionTimeoutError` and lists the matches, instead of acting on the first. Scripts that relied on the first match need `first()`, `nth()`, `exact` or more specific text. See [Locators](/driver/locator). ### Removed paths | Before | Now | | - | - | | `POST /workflows`, `GET /workflows`, `GET`, `PATCH` and `DELETE /workflows/{workflow_id}` | The same operations on `/agents` and `/agents/{agent_id}`. An agent takes `code` or `instructions`. | | `POST /workflows/{workflow_id}/runs` | `POST /runs` with `agent_id` for one run, or `POST /runs:batchCreate` for up to 1,000. | | `GET /workflows/{workflow_id}/code` | `GET /agents/{agent_id}/revisions/{revision_id}`, whose `code` holds the script. The agent's `current_revision_id` names the current one. | | `POST /workflows/{workflow_id}/revisions` with `source` | `POST /agents/{agent_id}/revisions` with `kind` (`code` or `instructions`) and `code` or `instructions`. | | `GET /workflows/{workflow_id}/revisions[/{revision_id}]` | `GET /agents/{agent_id}/revisions[/{revision_id}]` | | `POST /workflows/{workflow_id}/revisions/{revision_id}:restore` | `POST /agents/{agent_id}:rollback` with `{"revision_id": "..."}` | | `GET /workflows/{workflow_id}/run-stats` | `GET /runs/metrics?group_by=agent` | | `GET /runs/history` | Archived run records have no list endpoint now. `GET /runs` lists recent runs, and `GET /runs/metrics` counts them over a window. | ### Renamed and removed fields | Where | Before | Now | | - | - | - | | Run | `error_message` | `error` | | Run | `workflow_id`, `workflow_name` | `agent_id`, `agent_name`. An ad hoc run has neither. | | Run start | `runs[]` entries with `variables` as a one-element list | One `POST /runs` body per run, with `variables` as an object. Batches send `requests[]`. | | `GET /runs` | `workflow_id` | `agent_id` | | Session (list item and detail) | `workflow_id`, `workflow_name`; `source` `workflow` | `agent_id`, `agent_name`; `source` `agent` | | Session `allocated_by` | `workflow_editor` | Removed. The values are `dashboard_run`, `api_run` and `interactive`. | | `GET /phones/sessions`, `GET /usage/sessions` | `workflow_id` filter; `order_by` field `workflow_id` on usage | `agent_id` | | Usage session | `workflow_id` | `agent_id` | | `POST /phones:allocate` | `workflow_id` | Removed. | | Allocation response | `workflow_started_at` | `allocated_at` | | Release response | `workflow_id` | `agent_id` | | Plans (`GET /billing/plans`) | `limits.max_workflows` | `limits.max_agents` | The CLI's `runs watch -o json` ends with `error` in place of `error_message`: ```json theme={null} {"error":null,"run_id":"...","status":"completed","watch_end":true} ``` ### Changed behavior * A run has a `mode`: `agent` when a model drives it, `code` when a script runs. Every former workflow run is a code run. * A run can be `paused`, which joins the run status enum and the `status` filter on `GET /runs`. * `POST /runs` returns `409` while your organization is at its concurrency limit. * After an agent-mode run's turn ends, or after you cancel a running or paused agent-mode run, the run holds its phone for `keep_alive_seconds`: 120 seconds by default on `POST /runs`, 0 in a batch. A shared phone bills for that time. Release it with `POST /phones/{phone_id}:deallocate` and the run's `session_id`, or set `keep_alive_seconds: 0`. See [Pause and follow up](/agents/pause-and-follow-up#release-the-phone-early). ### New endpoints and fields * Agents: `/agents`, their revisions, `:rollback`, and `/revisions/{revision_id}:promote` for a fix a repair proposed. See [Create an agent](/agents/create). * Runs: `POST /runs` takes an ad hoc `task` or an `agent_id`, `targets`, `network`, `keep_alive_seconds` and limits. `:pause`, `:resume` and `:sendMessage` pause, answer and follow up. See [Run a task or an agent](/agents/run). * A run carries `mode`, `task`, `output`, `result_text`, `usage`, `input_request`, `repair`, `turn`, `turns`, `keep_alive_expires_at` and `stream_url`. `GET /runs` filters by `schedule_id`, `mode` and `device_class`. * `GET /realtime/runs/{run_id}/events` streams a run, and `GET /runs/{run_id}/events`, `/steps` and `/script` read its events, model steps and executed code. See [Monitor runs](/agents/monitor). * Schedules: `/agents/{agent_id}/schedules`, with `:pause`, `:resume` and `:run`. See [Schedule an agent](/agents/schedules). * Metrics: `GET /runs/metrics` and `GET /sessions/metrics`, on the [metrics contract](#metrics). `GET /usage/metrics` adds the `agent`, `model`, `api_key`, `schedule` and `device_class` groups, and the `session_seconds`, `input_tokens` and `output_tokens` measures. * `GET /usage/inferences` adds the `agent_chat` endpoint: one model step of an agent-mode run. * `GET /models` lists the models an agent can use. ### Agent webhook payloads | Events | Before | Now | | - | - | - | | `run.*` | `workflow_id` | `agent_id`, plus `mode`, `revision_id`, `schedule_id`, `repaired` and `turn` | | `session.started`, `session.ended` | `workflow_id` | `agent_id` | New events: `run.paused`, `run.resumed`, `schedule.paused`, `schedule.ended` and `schedule.occurrence_skipped`. A run with follow-ups fires `run.started` and its end event once per turn. See [Webhook events](/webhooks/events). ## Naming rules API 0.126.0 makes the REST API follow one set of naming rules everywhere. Some parameters, response fields, paths and webhook fields have new names. The old names stop working in this release, so update your code and upgrade your clients. The same release puts every metrics endpoint on one contract (see [Metrics](#metrics)). ### Upgrade your clients The SDK and CLI releases that follow API 0.126.0 use the new names. Earlier releases call the old ones and fail. ```bash theme={null} pip install -U axilio go get github.com/axilioai/platform-go@latest brew upgrade axilio ``` ### The rules | Concept | Rule | | - | - | | Sort | One `order_by= [asc\|desc]`, for example `order_by=created_at desc` (URL-encoded as `created_at%20desc`). The direction defaults to `desc`. | | Filters | Named after the field they match: `status`, never `status_filter`. Most filters repeat to match several values. | | Search | Free text is `search`. | | Time windows | `_after` and `_before`, over the field they filter, for example `allocated_after`. | | Metrics | Every `GET /{area}/metrics` takes the same window and returns `{"points": [...]}`. See [Metrics](#metrics). | | Timestamps | Every timestamp field ends in `_at`. | | Money | Every amount is in microdollars: 1,000,000 = \$1.00, so cents × 10,000. Fields end in `_microdollars`. | | Updates | `PATCH`, with only the fields you are changing. | | Actions | A `:verb` on the resource, for example `POST /files/{file_id}:complete`. | | Paths | A `POST` creates in a collection or runs a `:verb`. A subset of a list is a filter, never a path segment. | | Spelling | Runs and sessions say `cancelled` (`cancelled_at`). Billing keeps Stripe's `canceled`. | ### Paths and methods The Agents release later removed the workflow routes in this table. See [Removed paths](#removed-paths). | Before | Now | | - | - | | `GET /runs/stats/{workflow_id}` | `GET /workflows/{workflow_id}/run-stats` | | `POST /workflows/{workflow_id}/code/restore` with `revision_id` in the body | `POST /workflows/{workflow_id}/revisions/{revision_id}:restore`, no body | | `PATCH /phones/{phone_id}/nickname` | `PATCH /phones/{phone_id}` with `{"nickname": "..."}` | | `POST /files/{file_id}/complete` | `POST /files/{file_id}:complete` | | `POST /workflows/{workflow_id}/code` | `POST /workflows/{workflow_id}/revisions` | | `POST /phones/sessions/{session_id}/live-view-token` | `POST /phones/sessions/{session_id}:createLiveViewToken` | | `POST /phones/sessions/{session_id}/telemetry-token` | Removed. Read `GET /realtime/sessions/{session_id}/events` with your API key; see [Live Telemetry](/telemetry/live). | | `GET /phones/apps/supported` | `GET /phones/supported-apps` | | `GET /phones/sessions/active` | `GET /phones/sessions?status=active` | | `PUT /billing/auto-recharge` | `PATCH /billing/auto-recharge` | | `PUT /billing/usage-alerts` | `PATCH /billing/usage-alerts` | ### Query parameters | Endpoint | Before | Now | | - | - | - | | `GET /runs` | `status_filter`, `trigger_filter` | `status`, `trigger` | | `GET /runs/history` | `start_date`, `end_date`, `status_filter` | `completed_after`, `completed_before`, `status` | | `GET /usage/metrics` | `start_date`, `end_date`, `granularity` (`hourly`, `daily`), `timezone` | `period_start_at`, `period_end_at`, `granularity` (`hour`, `day`), `time_zone`, `group_by` | | `GET /webhooks/metrics` | `range` (`24h`, `7d`, `30d`); points grouped by destination when `endpoint_id` was left out | `period_start_at`, `period_end_at`, `granularity`, `time_zone`; `group_by=endpoint_id` to group by destination | | `GET /usage/sessions` | `start_date`, `end_date`, `session_status_filter`, `processed_status_filter` | `allocated_after`, `allocated_before`, `session_status`, `processed_status` | | `GET /usage/sessions` | `order_by` field `duration` | `duration_seconds` | | `GET /usage/inferences` | `start_date`, `end_date`, `endpoint_filter` | `created_after`, `created_before`, `endpoint` | | `GET /phones` | `sort`, `order`, `type`, `last_active_after`, `last_active_before` | `order_by`, `phone_type`, `last_heartbeat_after`, `last_heartbeat_before` | | `GET /phones/sessions` | `sort`, `order`, `started_after`, `started_before`, `ended_after`, `ended_before` | `order_by`, `allocated_after`, `allocated_before`, `deallocated_after`, `deallocated_before` | | `GET /phones/sessions/{session_id}/files` | `q` | `search` | | `GET /files` | `sort`, `order`, `q` | `order_by`, `search` | | `GET /billing/history` | `sort_by`, `sort_order`, `date_from`, `date_to` | `order_by`, `invoiced_after`, `invoiced_before` | | `GET /billing/phone-rental-subscriptions` | none: every rental in one response | pages with `limit` (default and maximum 100) and `offset`; filters `search`, `status`, `plan_interval`, `cancel_at_period_end`, `payment_status`, `current_period_end_after`, `current_period_end_before`; sorts with `order_by` | `GET /api-keys` also takes `search`, `created_by_id`, `created_after`, `created_before` and `order_by`. `GET /workflows` also takes `order_by`, whose field is a path into the listed item, for example `order_by=workflow.name asc`. Both are additions, so existing calls keep working. ### Response fields | Resource | Before | Now | | - | - | - | | Phone | `last_heartbeat`, `last_interaction_timestamp` | `last_heartbeat_at`, `last_interaction_at` | | Session (list item and detail) | `nickname` | `phone_nickname` | | Run | `canceled_at` | `cancelled_at` | | Run history item | `run_id`, `org_id`, `canceled_at` | `id`, `organization_id`, `cancelled_at` | | Usage metrics (costs were US dollars as decimals) | `infra_costs`, `compute_minutes`, `cost_by_product`, the chart series, `period_start`, `period_end`, `granularity` | `points`: one per bucket with `period_start_at`, `total_cost_microdollars`, `session_cost_microdollars` and `inference_cost_microdollars` (and `product` with `group_by=product`). Add the points up for a total. | | Supported app | `model_status` in upper case (`TRAINED`) | lower case (`trained`) | | Usage session (`GET /usage/sessions`) | `allocated_by` in upper case (`WORKFLOW_EDITOR`) | lower case (`workflow_editor`), as on `GET /phones/sessions` | | Balance | `purchased_next_expiry` | `purchased_expires_at` | | Subscription | `current_period_start`, `current_period_end`, `trial_end`, `pending_downgrade_effective_date`, `monthly_price`, `yearly_price` | `current_period_start_at`, `current_period_end_at`, `trial_end_at`, `pending_downgrade_at`, `monthly_price_microdollars`, `yearly_price_microdollars` | | Auto-recharge settings | `threshold_cents`, `target_cents` | `threshold_microdollars`, `target_microdollars` | | Usage-alert settings and active alerts | `threshold_cents`, `balance_cents` | `threshold_microdollars`, `balance_microdollars` | | Invoice (billing history) | `invoice_date`, `due_date`, `amount_cents`, `amount_dollars`, `formatted_amount`; `billing_cycle` as `month` or `year` on synced invoices | `invoiced_at`, `due_at`, `amount_microdollars`, `amount_display`; `billing_cycle` is always `daily`, `weekly`, `monthly` or `yearly` | | Phone rental subscription | `current_period_start`, `current_period_end`, `plan_price_cents` | `current_period_start_at`, `current_period_end_at`, `plan_price_microdollars` | | Phone rental summary | `combined_monthly_cents`; each interval's `monthly_cents`; the upcoming charge's `amount_cents` and `date` | `combined_monthly_microdollars`; `monthly_microdollars`; `amount_microdollars` and `due_at` | | Webhook delivery metrics point | `time_bucket`, null on an aggregate | `period_start_at`, always set; `endpoint_id` only with `group_by=endpoint_id` | | Plans (`GET /billing/plans`, public) | `pricing.monthly_price_cents`, `pricing.yearly_price_cents`, `sort_order` | `pricing.monthly_price_microdollars`, `pricing.yearly_price_microdollars`, `display_order` | | Phone rental plans (`GET /billing/phone-rental-plans`, public) | `price_cents` | `price_microdollars` | | Model catalog (Argus `GET /models`) | `pricing.input`, `pricing.output` (US dollars per token, as strings); `pricing.per_page` (US dollars per call, as a string) | `pricing.input_microdollars_per_million_tokens`, `pricing.output_microdollars_per_million_tokens`, `pricing.per_page_microdollars` (integers) | ### Metrics `GET /usage/metrics` and `GET /webhooks/metrics` follow one contract, and every metrics endpoint added later will too. | Parameter | Rule | | - | - | | `period_start_at`, `period_end_at` | Both optional. The end defaults to now, and the start to 7 days before the end. The start may be at most a year before now. The window holds its start and not its end. | | `granularity` | `hour` or `day`, default `day`. `hour` is allowed for windows of 7 days or less; a longer one is a 422. | | `time_zone` | An IANA name such as `America/Los_Angeles`, default `UTC`. A day starts at local midnight and an hour on the local hour. An unknown name is a 422. | | `group_by` | Optional, one dimension from the endpoint's list: `product` on usage, `endpoint_id` on webhooks. | The response is `{"points": [...]}`: one point per bucket (and per group), oldest first, each with `period_start_at` and every measure. A measure is named for its unit (`_microdollars`, `_ms`, `_rate` from 0 to 1, or a plain count) and is zero for a bucket with no activity. A latency is null when no attempt in the bucket recorded one. A webhooks window that needs more buckets than the gateway returns in one read is a 400; shorten it. ### Webhook payloads | Events | Before | Now | | - | - | - | | `run.*` | `canceled_at` | `cancelled_at` | | `invoice.paid`, `invoice.payment_failed` | `amount_cents`, `invoice_date` | `amount_microdollars`, `invoiced_at` | | `subscription.*` | `current_period_start`, `current_period_end` | `current_period_start_at`, `current_period_end_at` | | `phone.rental.*` | `plan_price_cents`, `current_period_start`, `current_period_end` | `plan_price_microdollars`, `current_period_start_at`, `current_period_end_at` | A phone rental's `canceled_at` and a subscription's `canceled` status keep Stripe's spelling. ### SDK methods The SDKs take the new parameter names as arguments (`period_start_at=` in Python, `PeriodStartAt` in Go). These methods also moved: | Python before | Python now | Go now | | - | - | - | | `client.runs.stats(workflow_id)` | `client.workflows.get_run_stats(workflow_id)` | `api.Workflows.GetRunStats` | | `client.workflows.restore_revision(workflow_id, revision_id=...)` | `client.workflows.restore_revision(workflow_id, revision_id)` | `api.Workflows.RestoreRevision` with `WorkflowsRestoreRevisionRequest` | | `client.phones.nickname(...)` | `client.phones.update(phone_id, nickname=...)` | `api.Phones.Update` | | `client.files.rename(file_id, filename=...)` | `client.files.update(file_id, filename=...)` | `api.Files.Update` | | `client.workflows.save_code(workflow_id, source=...)` | `client.workflows.create_revision(workflow_id, source=...)` | `api.Workflows.CreateRevision` with `WorkflowCreateRevisionRequest` | | `client.phones.active_sessions()` | `client.phones.list_sessions(status=["active"])` | `api.Phones.ListSessions` | | `client.billing.update_auto_recharge(threshold_cents=..., target_cents=...)` | `client.billing.update_auto_recharge(threshold_microdollars=..., target_microdollars=...)` | `api.Billing.UpdateAutoRecharge` with `SubscriptionAutoRechargeSettingsUpdate` | | `client.billing.update_usage_alerts(threshold_cents=...)` | `client.billing.update_usage_alerts(threshold_microdollars=...)` | `api.Billing.UpdateUsageAlerts` with `SubscriptionUsageAlertSettingsUpdate` | # Elements: locate Source: https://docs.axilio.ai/api-reference/argus/elements-locate api-reference/openapi-argus.json POST /api/v1/elements:locate Find the element a query describes among the given UI elements. Answers with one of the request's element ids, or not found; never a point. Choose the UI element that a natural-language query describes from a list of elements you already have. You send the candidates, and Argus answers with one of your element `id` values as `element_id`, or `found: false`. It never answers with a point, so you act on the element itself. ## Why elements and screenshots are separate operations Screenshot grounding and element grounding take different inputs and give different answers. A screenshot locate reads pixels and returns a box. An element locate reads structured elements and returns an element ID. Keeping them as separate operations gives each one a request and response that fit it exactly. The element operation doesn't care where your elements came from. A browser DOM, the results of [Screenshots: detect](/api-reference/argus/screenshots-detect), or any other UI tree all map onto the same shape: an `id`, a `role`, a `name`, an optional `value`, optional pixel `bounds`, and an optional `platform` object of native attributes, which Argus passes to the model as given. ## Request * **`query`** describes the target, such as `"the Sign in button"`. Up to 2,000 characters. * **`elements`** holds 1 to 2,000 candidates. Each `id` must be unique in the request; a duplicate is a validation error. An `id` is at most 256 characters, a `role` at most 64, a `name` or `value` at most 1,024, and a `platform` object at most 4 KiB as JSON. * **`image`** is an optional base64 PNG or JPEG screenshot. When you send one, give `bounds` in that screenshot's pixel space. * **`model`** is an optional vision-language model from [Models: list](/api-reference/argus/list-models). Omit it to use the default. ```json theme={null} { "query": "the Sign in button", "elements": [ {"id": "12", "role": "textbox", "name": "Email", "value": ""}, {"id": "13", "role": "textbox", "name": "Password"}, { "id": "14", "role": "button", "name": "Sign in", "bounds": {"x": 96, "y": 1480, "width": 888, "height": 144} } ] } ``` ## Response `found` tells you whether the model chose an element. When it did, `element_id` is one of the IDs you sent. Argus checks the answer against your request, so an ID you didn't send comes back as not found rather than as a guess. `confidence` is the model's own estimate from 0 to 1. ```json theme={null} { "found": true, "element_id": "14", "confidence": 0.97, "model": "anthropic/claude-haiku-4.5", "cost_microdollars": 412, "prompt_tokens": 1210, "completion_tokens": 18, "latency_ms": 640 } ``` ## Billing Element locate is billed per token on the model it ran, the same way as [Screenshots: locate](/api-reference/argus/screenshots-locate). Usage rows label it with the endpoint `element_locate`. See [Usage](/usage/overview#inference-usage). Errors use the [problem details](/reference/errors#problem-details) shape. # Models: list Source: https://docs.axilio.ai/api-reference/argus/list-models api-reference/openapi-argus.json GET /api/v1/models List every model Argus supports, with pricing. Two families: the curated VLMs served by the locate methods (per-token pricing; pass their id as `model`) and the Axilio model line behind screenshots:detect (per-page pricing; selected via `ocr_engine` / `inference_type` — lite is the free engine, pro is premium). Public: no API key required (it's a catalog of model names and public prices, nothing sensitive), so a client can discover supported models before it holds credentials. List every model Argus serves, with live pricing. The catalog is public, so this request does not need an API key. The catalog has two families: * **Vision-language models** (`type: "vlm"`) answer the locate operations. Pass one as `model` to [Screenshots: locate](/api-reference/argus/screenshots-locate) or [Elements: locate](/api-reference/argus/elements-locate). They bill per token. * **Axilio models** (`axilio/argus-*`) run behind [Screenshots: detect](/api-reference/argus/screenshots-detect). You select them with `ocr_engine` and `inference_type`, not by ID. They bill per page, where one screenshot is one page. A locate request with a model ID that isn't in this list is rejected with `400` before anything is billed. See [Models](/drivers/models) for how the driver picks each one. # Screenshots: detect Source: https://docs.axilio.ai/api-reference/argus/screenshots-detect api-reference/openapi-argus.json POST /api/v1/screenshots:detect Detect UI elements and read text in a screenshot. Detect icons and UI elements and read the text in a screenshot you supply. Send the image as base64 PNG or JPEG. `inference_type` chooses icons (`detect`), text (`ocr`), or both (`combined`), and `ocr_engine` chooses the free or premium text engine. Detection is billed per page through the Axilio model line. See [Models](/drivers/models#axilio-models) for the tiers and prices. To find one described element instead of every element, use [Screenshots: locate](/api-reference/argus/screenshots-locate). You can pass the text this operation returns as that request's `texts`. Errors use the [problem details](/reference/errors#problem-details) shape. # Screenshots: locate Source: https://docs.axilio.ai/api-reference/argus/screenshots-locate api-reference/openapi-argus.json POST /api/v1/screenshots:locate Find the element a query describes in a screenshot: a point, or an OCR text. Find the element a natural-language query describes, such as `"the blue Continue button"`, in a screenshot you supply. A vision-language model reads the image and answers in one of two ways when `found` is true: * **A box.** `bbox` is the element's pixel box in the screenshot. * **A matched text.** When you send `texts`, the OCR results for the same screenshot, the model can answer with `matched_text_index` instead. It points into your `texts` list, so you reuse that entry's exact box. Argus does not run OCR for this operation. To ground answers in text, call [Screenshots: detect](/api-reference/argus/screenshots-detect) first and pass its text results as `texts` unchanged: detect already returns each text box normalized to `[0, 1]` of the image, which is the form `texts` takes. Only the answer's `bbox` is in pixels. Pass a `model` from [Models: list](/api-reference/argus/list-models), or omit it to use the default. The call is billed per token on that model, and the response reports `prompt_tokens`, `completion_tokens`, and `cost_microdollars`. If you already have the screen's UI elements, for example from a DOM, use [Elements: locate](/api-reference/argus/elements-locate) instead. It answers with one of your elements, which is exact where a box is approximate. Errors use the [problem details](/reference/errors#problem-details) shape. # Device Control Protocol Source: https://docs.axilio.ai/api-reference/dcp/overview The raw wire that drives a phone — CDP-shaped JSON-RPC over one WebSocket per session. Every phone you allocate exposes a `control_url`: a single WebSocket that drives the phone. The Device Control Protocol (DCP) is what rides on that socket. The [Python and Go mobile drivers](/driver/overview) speak it for you, but the socket is yours — you can open it directly and drive a phone from any language with a WebSocket client. DCP is to phones what the Chrome DevTools Protocol is to browsers: a small, literal message protocol you can drive raw. Commands go down, results and notifications come back, one phone per connection. ## Two layers, one protocol * **The SDK is the friendly path.** [`MobileDriver`](/driver/reference) adds plain-English selectors, waiting, and retries on top of DCP. Reach for it first — most code should. * **The raw protocol is the power path.** Open `control_url` yourself when you work outside Python, embed control in your own runtime, or want the exact frames on the wire with nothing in between. Both drive the same phone through the same socket. The mobile drivers implement their high-level actions by sending these DCP methods. ## How the wire looks DCP is JSON-RPC framed like the Chrome DevTools Protocol — the frame *is* the whole payload, at the top level, with no envelope. A command looks like this: ```json theme={null} { "id": 1, "method": "Touch.tap", "params": { "x": 540, "y": 1180 } } ``` The phone returns a response with the same `id`: ```json theme={null} { "id": 1, "result": {} } ``` Each command carries an `id`; its response echoes that `id`. A method name is `Domain.method` — `Touch.tap`, `Screen.observe`, `Device.info`. A phone advertises the domains it supports in the [handshake](/api-reference/dcp/reference#protocol-handshake), so you discover its capabilities instead of assuming them. ## Connection lifetime The phone session outlives an individual WebSocket. The Python and Go mobile drivers reconnect automatically. If you manage a raw client, classify socket closes, resume with the opaque cursor the server provides, repeat the handshake, and reuse idempotency keys when you resend an ambiguous input command. See [Reconnect and resume](/api-reference/dcp/reconnect) for the complete raw client contract. ## What DCP drives DCP is a phone protocol. A phone advertises these capability domains: | Domain | What it does | | - | - | | `Protocol` | Handshake — negotiate the protocol version and discover capabilities. | | `Device` | Read the static device descriptor (screen size, platform, input modalities). | | `Screen` | Screenshot and observe (OCR + icons). | | `Locator` | Find a target by text or description and act on it in one round trip. Actions wait for the target; `count` returns at once. See [Locator methods](/api-reference/dcp/reference#locator). | | `Touch` | Tap, long-press, and swipe at pixel coordinates. | | `Keyboard` | Type text and press named keys. | ## Next steps > [card] [Drive a phone with raw DCP](/api-reference/dcp/raw-usage) > > Open the socket, handshake, and send your first command. > [card] [Message reference](/api-reference/dcp/reference) > > Every method, its params, and its result. > [card] [Reconnect and resume](/api-reference/dcp/reconnect) > > Recover from a dropped socket without a gap or double input. > [card] [Get a phone](/devices/allocate) > > Allocate a phone and read its `control_url`. > [card] [Use the SDK instead](/driver/overview) > > The ergonomic layer over DCP. # Drive a phone with raw DCP Source: https://docs.axilio.ai/api-reference/dcp/raw-usage Open the control socket yourself, handshake, and send commands straight over the wire. You can drive a phone without the SDK: allocate a phone, open its `control_url`, and send [DCP](/api-reference/dcp/overview) frames. This is the same socket the SDK uses — here you speak to it directly. The examples use Python's `websockets` library, but nothing is Python-specific. Any WebSocket client works. ## Before you start * **An API key** — create one in [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys). See [Authentication](/authentication). * **A dedicated phone ID** — choose an idle phone in [**Phones**](https://app.axilio.ai/dashboard/axilio/phones) or run `axilio phones list`. * **A WebSocket client.** For the examples: `pip install axilio websockets`. ## 1. Get a phone and its control URL Allocate a phone through the [REST API](/api-reference/overview) or the SDK. The allocation returns a `control_url` — a WebSocket scoped to this one session: ```python theme={null} from axilio.platform import Client client = Client() device = client.phones.allocate(phone_type="android", phone_id="ph_123") control_url = device.control_url ``` > [!NOTE] > > The `control_url` is self-contained and embeds its credential, so you can > connect without extra headers. Pass it directly to your WebSocket client. > Never put it in logs, shell history, tickets, or chat, and do not cache it > across sessions. It stops working when the session ends. ## 2. Open the socket and handshake Send [`Protocol.handshake`](/api-reference/dcp/reference#protocol-handshake) first. It returns the phone's protocol version, device descriptor, and the exact set of methods it supports — gate your code on that instead of assuming: ```python theme={null} import asyncio import json import websockets async def drive(control_url): async with websockets.connect(control_url) as ws: # Each command carries an id; the response echoes it. await ws.send(json.dumps({"id": 1, "method": "Protocol.handshake", "params": {}})) hello = json.loads(await ws.recv()) caps = hello["result"]["capabilities"] device = hello["result"]["device"] print("screen:", device["screen_width"], "x", device["screen_height"]) print("supports:", caps) asyncio.run(drive(device.control_url)) ``` ## 3. Send a command Commands are `{ "id", "method", "params" }`; the phone replies with `{ "id", "result" }` on success or `{ "id", "error" }` on failure. Tap a pixel: ```python theme={null} await ws.send(json.dumps({ "id": 2, "method": "Touch.tap", "params": { "x": 540, "y": 1180, "idempotencyKey": "", }, })) reply = json.loads(await ws.recv()) if "error" in reply: raise RuntimeError(reply["error"]["message"]) ``` ## 4. Read the screen `Screen.observe` returns the on-screen text and icons with their bounding boxes — enough to decide where to tap next: ```python theme={null} await ws.send(json.dumps({"id": 3, "method": "Screen.observe", "params": {}})) screen = json.loads(await ws.recv())["result"] for item in screen["texts"]: print(item["text"], item["bbox"]) ``` ## Correlating replies Responses can arrive in any order, so match a reply to its command by `id` instead of assuming the next frame is the answer. A frame with no `id` is a notification, not a response. A small pattern: keep an outstanding-id map, increment a counter per command, and resolve each reply against it. The [SDK](/driver/overview) does exactly this for you. The `idempotencyKey` is separate from the request `id`. Generate one UUID per logical `Touch.*` or `Keyboard.*` command. Reuse that key only if you must resend the same command after a dropped connection. See [Reconnect and resume](/api-reference/dcp/reconnect). ## Next steps > [card] [Message reference](/api-reference/dcp/reference) > > Every method, its params, its result, and the error shape. > [card] [Reconnect and resume](/api-reference/dcp/reconnect) > > Handle close codes, cursors, replay, and safe resends. > [card] [Live telemetry](/telemetry/live) > > Stream the session's Telemetry frames on a second socket. > [card] [Use the SDK instead](/driver/overview) > > Selectors, waiting, and retries over DCP. # Reconnect and resume Source: https://docs.axilio.ai/api-reference/dcp/reconnect Recover a raw DCP WebSocket with close classification, cursor resume, replay tolerance, resync, and idempotent command resends. A phone session outlives its individual WebSocket connections. A network path can change, a service can restart, or a laptop can switch networks while the same allocation remains active. Reconnect to the same `control_url` and continue the session. > [!NOTE] > > The Python and Go mobile drivers implement control reconnect, cursor resume, > handshake replay, and idempotent input resends. Follow this page when you > manage a raw control WebSocket yourself. See > [Telemetry reliability](/telemetry/reliability) for the separate read-only > stream. ## Classify the close Use the socket close code to decide whether to retry: | Close | Meaning | Client action | | - | - | - | | `1001` Going Away | The service is shutting down or its ping timed out. | Reconnect with bounded backoff. | | `1013` Try Again Later | A draining service refused this connection. | Retry the same URL with bounded backoff. | | `1011` Internal Error | The service failed while setting up or handling the connection. | Retry with bounded backoff. | | Abrupt loss | The network path ended without a close frame. | Treat it like a retryable `1001`. | | `1000` with `session ended` | The allocation ended. | Stop. | | `1000` with `Superseded` | A newer connection replaced this control connection. | Stop this connection. | | `4409` with `control_held` | Another code-control surface holds the session. | Stop and surface the conflict. Do not retry-loop. | Use exponential backoff with jitter and a bounded attempt budget. On a refused WebSocket upgrade, `401` means the credential is invalid and `403` means the session is no longer active. Both are terminal for that URL. The session-scoped control URL remains valid for reconnects while its allocation is active. Do not mint a different control URL during a retry. ## Opt in to cursor checkpoints Add `resume=1` to the URL on the first connection. The server then sends an opaque checkpoint after each delivered batch: ```json theme={null} { "method": "Axilio.cursor", "params": { "cursor": "" } } ``` Treat a cursor as an opaque string. Store the latest checkpoint only after your client has processed all DCP frames that came before it. Never parse or construct a cursor. Without `resume=1`, the server sends no cursor checkpoints. A fresh control connection starts at the live tail. ## Present the cursor on reconnect Reconnect to the original URL. Parse it with a standard URL query API, preserve its existing parameters, and set or replace `resume=1` and `cursor` with the last completed checkpoint. Pass the opaque cursor unchanged as the parameter value so the API encodes only that value. Never concatenate it into the URL or encode the complete URL: ```text theme={null} wss://connect.axilio.ai/api/v1/realtime/ws/control?token=...&resume=1&cursor=... ``` Delivery continues strictly after that checkpoint. Resume is **at least once**: frames delivered after your last completed checkpoint can arrive again. Your client must tolerate duplicates. ## Handle an expired replay window The control replay window is bounded. It retains roughly two minutes and about 100 frames. When the cursor predates the retained window, the server sends a resync signal as the first stream-facing frame and then continues from the live tail: ```json theme={null} { "method": "Axilio.resyncRequired", "params": { "requested": "", "oldest": "" } } ``` After a control resync, treat your device view as stale and call `Screen.observe` again before deciding the next action. ## Resend input exactly once Cursor resume protects frames sent **to** your client. A connection can still drop after you send an input but before its response arrives. At that point, you do not know whether the phone executed it. Give every logical `Touch.*`, `Keyboard.*`, and input `Locator.*` command (`tap`, `fill`, `press`) a client-generated `idempotencyKey`: ```json theme={null} { "id": 18, "method": "Touch.tap", "params": { "x": 120, "y": 640, "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000" } } ``` Generate one unique key per logical input. If its result is ambiguous after a drop, reconnect and resend the same command with a new request `id` and the same `idempotencyKey`. The session records the first response and returns it to the duplicate without executing the input again. Do not reuse an idempotency key for a different command. `Locator.tap`, `Locator.fill`, and `Locator.press` take a key like the `Touch` and `Keyboard` inputs. Read operations such as `Screen.observe`, `Screen.screenshot`, `Device.info`, and the other `Locator` methods do not need one. ## Keep request IDs monotonic Never reuse a DCP request `id` during the lifetime of your client. A resumed connection can replay a response created before the drop. Reusing its `id` could attach that old response to new work. Continue one monotonically increasing request counter across reconnects. Match each response by `id`, ignore id-less notifications after handling them, and discard a stale response whose `id` belongs to an already-resolved request. ## Repeat the handshake Capability state belongs to a connection. After every reconnect, send [`Protocol.handshake`](/api-reference/dcp/reference#protocol-handshake) and process its response before sending more work. Re-evaluate `capabilities` instead of assuming they match the prior socket. The server sends WebSocket pings for keepalive. Standard WebSocket libraries answer them automatically, so you do not need an application-level DCP ping. ## Raw client checklist 1. Reconnect to the original session URL with the last completed cursor. 2. Process cursor notifications and persist only completed checkpoints. 3. On `1001`, `1011`, `1013`, or abrupt loss, retry with bounded jittered backoff and the last cursor. 4. Stop on a terminal close or a `401`/`403` upgrade refusal. 5. If the server requests a resync, rebuild device state. 6. Keep DCP request IDs monotonic across sockets. 7. Repeat `Protocol.handshake` after each reconnect. 8. Reuse the same idempotency key only when resending the same ambiguous input. 9. Tolerate replayed DCP frames because cursor resume is at least once. ## Next steps > [card] [Drive a phone with raw DCP](/api-reference/dcp/raw-usage) > > Open the control socket and send your first command. > [card] [DCP message reference](/api-reference/dcp/reference) > > Review methods, parameters, results, and errors. > [card] [Telemetry reliability](/telemetry/reliability) > > Resume the read-only stream and refill a Telemetry gap. # DCP message reference Source: https://docs.axilio.ai/api-reference/dcp/reference Every DCP method, its params, and its result — plus the error shape. Every [DCP](/api-reference/dcp/overview) method, grouped by capability domain. A command is `{ "id", "method", "params" }`; the response echoes the `id` and carries `result` on success or `error` on failure. Coordinates are pixels in the phone's native resolution (read it from [`Device.info`](#device-info)). A phone advertises the methods it supports in the [handshake](#protocol-handshake) — check `capabilities` before you depend on a method rather than assuming the whole surface. ## Protocol ### `Protocol.handshake` Negotiate the protocol and discover what the phone supports. Send it once, right after you open the socket, and repeat it after every reconnect before sending more work. ```json theme={null} { "id": 1, "method": "Protocol.handshake", "params": {} } ``` | Param | Type | Required | Description | | - | - | - | - | | `client_version` | string | No | Reserved informational field; the phone currently accepts but does not act on it. | | `min_protocol` | integer | No | Informational; the phone reports its own version in the result. | **Result** | Field | Type | Description | | - | - | - | | `protocol_version` | integer | The protocol version the phone speaks. | | `device` | object | The [device descriptor](#device-info), same shape as `Device.info`. | | `domains` | string\[] | The capability domains this phone advertises, e.g. `["Device", "Keyboard", "Locator", "Protocol", "Screen", "Touch"]`. | | `capabilities` | string\[] | The exact method names this phone answers, e.g. `["Touch.tap", "Screen.observe", ...]`. | ## Device ### `Device.info` The static device descriptor — read it to size taps and swipes without a screenshot round trip. ```json theme={null} { "id": 2, "method": "Device.info", "params": {} } ``` **Result** | Field | Type | Description | | - | - | - | | `device_id` | string | Stable id for this phone. | | `platform` | string | OS family — `"android"`. | | `form_factor` | string | Physical class — `"phone"`. | | `input_modalities` | string\[] | Interaction classes the phone accepts — `["touch", "keyboard"]`. | | `screen_width` | integer | Native width in pixels. | | `screen_height` | integer | Native height in pixels. | | `model` | string | Best-effort model name; may be empty. | | `os_version` | string | Best-effort OS version; may be empty. | ## Touch ### `Touch.tap` Tap a pixel. ```json theme={null} { "id": 3, "method": "Touch.tap", "params": { "x": 540, "y": 1180 } } ``` | Param | Type | Required | Description | | - | - | - | - | | `x` | integer | Yes | X coordinate in pixels. | | `y` | integer | Yes | Y coordinate in pixels. | | `idempotencyKey` | string | No | Unique key for this logical input. Reuse it only when resending the same command after reconnect. | **Result** — an empty object `{}`. ### `Touch.longPress` Press and hold a pixel. ```json theme={null} { "id": 4, "method": "Touch.longPress", "params": { "x": 540, "y": 1180, "duration_ms": 800 } } ``` | Param | Type | Required | Description | | - | - | - | - | | `x` | integer | Yes | X coordinate in pixels. | | `y` | integer | Yes | Y coordinate in pixels. | | `duration_ms` | integer | No | Hold duration in milliseconds. | | `idempotencyKey` | string | No | Unique key for this logical input. Reuse it only when resending the same command after reconnect. | **Result** — an empty object `{}`. ### `Touch.swipe` Swipe from one pixel to another. ```json theme={null} { "id": 5, "method": "Touch.swipe", "params": { "x1": 540, "y1": 1600, "x2": 540, "y2": 400, "duration_ms": 300 } } ``` | Param | Type | Required | Description | | - | - | - | - | | `x1`, `y1` | integer | Yes | Start pixel. | | `x2`, `y2` | integer | Yes | End pixel. | | `duration_ms` | integer | No | Swipe duration in milliseconds. | | `idempotencyKey` | string | No | Unique key for this logical input. Reuse it only when resending the same command after reconnect. | **Result** — an empty object `{}`. ## Keyboard ### `Keyboard.typeText` Type a string into the focused field. ```json theme={null} { "id": 6, "method": "Keyboard.typeText", "params": { "text": "hello" } } ``` | Param | Type | Required | Description | | - | - | - | - | | `text` | string | Yes | The text to type. | | `idempotencyKey` | string | No | Unique key for this logical input. Reuse it only when resending the same command after reconnect. | **Result** — an empty object `{}`. ### `Keyboard.keyPress` Press a supported named key. Raw DCP accepts `enter` and `capslock`; the CLI and SDKs expose Enter as their customer-facing named key. Use `Keyboard.typeText` for printable text. ```json theme={null} { "id": 7, "method": "Keyboard.keyPress", "params": { "key": "enter" } } ``` | Param | Type | Required | Description | | - | - | - | - | | `key` | string | Yes | `"enter"` or `"capslock"`. The server rejects other values. | | `idempotencyKey` | string | No | Unique key for this logical input. Reuse it only when resending the same command after reconnect. | **Result** — an empty object `{}`. ## Screen ### `Screen.screenshot` Capture the current frame as a PNG. ```json theme={null} { "id": 8, "method": "Screen.screenshot", "params": {} } ``` **Result** | Field | Type | Description | | - | - | - | | `png_base64` | string | The frame as a base64-encoded PNG. | ### `Screen.observe` Read the screen — its text and icons, with bounding boxes. ```json theme={null} { "id": 9, "method": "Screen.observe", "params": { "ocr_engine": "free" } } ``` | Param | Type | Required | Description | | - | - | - | - | | `ocr_engine` | string | No | `"free"` or `"premium"`. Defaults to `free`. | **Result** | Field | Type | Description | | - | - | - | | `texts` | object\[] | Detected text: `text`, `bbox` (`x`, `y`, `width`, `height`), `confidence`. | | `icons` | object\[] | Detected icons: `bbox`, `confidence`. | | `hash` | string | Content hash of the frame; unchanged means the screen didn't change. | | `width`, `height` | integer | Frame dimensions in pixels. | | `captured_at` | integer | Frame timestamp, epoch milliseconds. | ## Locator Each `Locator` method finds its target on the current frame, waits until the target is ready, then acts or answers, all on the phone side in one round trip. A locator is an object of target fields that combine as AND: | Field | Type | Description | | - | - | - | | `text` | string | Visible text; a case-insensitive substring unless `exact` is true. | | `exact` | boolean | Require an exact, case-sensitive text match. | | `query` | string | Plain-English description, read by a vision model. | | `within` | locator | Refine to a match inside this locator's match. | | `has` | locator | Refine to a match that contains this locator's match. | | `nth` | integer | Pick the nth match in reading order, zero-based. | Plain `text` resolves by OCR; anything more uses one vision-model call. The contract also defines `role`, `name`, `id`, `states`, and `platform`. They need the phone's accessibility tree, which phones don't expose yet, so they fail with `StrategyUnavailable`. Every Locator method accepts these params alongside its own: | Param | Type | Required | Description | | - | - | - | - | | `locator` | object | Yes (except `Locator.press`) | The target, as above. | | `timeoutMs` | integer | No | Budget for the whole call, waiting included. Default `5000`, maximum `60000`. Not on `Locator.count`. | | `ocrEngine` | string | No | `"free"` or `"premium"`. | | `model` | string | No | A vision model id; defaults to an automatic choice. | When the target never becomes ready within `timeoutMs`, the call fails with `ActionTimeout`. Most results are a **LocatorResult**: | Field | Type | Description | | - | - | - | | `resolvedBy` | string | How the target was found: `"ocr"` or `"vlm"`. | | `bounds` | object | Where the target was when the phone acted: `x`, `y`, `width`, `height`. | | `tookMs` | integer | Time on the phone, waiting included. | | `modelName` | string | The vision model that resolved the target, when `resolvedBy` is `"vlm"`. | ### `Locator.tap` Wait for the target, then tap its center. ```json theme={null} { "id": 10, "method": "Locator.tap", "params": { "locator": { "query": "the search icon" } } } ``` Also takes `idempotencyKey`. **Result** — a LocatorResult. When the locator reads the screen and matches several elements on a settled frame, this fails with `ActionTimeout` instead of tapping one, and so do `Locator.fill` and `Locator.press` with a locator. The message lists the matches. Pick one with `nth`, or narrow the locator with `exact`. ### `Locator.fill` Wait for the target, tap it to focus, then type `text` into it. ```json theme={null} { "id": 11, "method": "Locator.fill", "params": { "locator": { "text": "Search" }, "text": "axilio" } } ``` Requires `text`; also takes `idempotencyKey`. **Result** — a LocatorResult. ### `Locator.press` Press a named key: `"enter"` or `"capslock"`. With a `locator`, the target is found, waited for, and focused first; without one, the key goes to whatever has focus. ```json theme={null} { "id": 12, "method": "Locator.press", "params": { "locator": { "text": "Search" }, "key": "enter" } } ``` Requires `key`; also takes `idempotencyKey`. **Result** — a LocatorResult; `resolvedBy` and `bounds` are present only when the press carried a locator. ### `Locator.waitFor` Wait until the target reaches a state, re-checking each fresh frame. One call replaces a client-side polling loop. ```json theme={null} { "id": 13, "method": "Locator.waitFor", "params": { "locator": { "text": "Loading" }, "state": "hidden", "timeoutMs": 30000 } } ``` `state` is `"visible"` (the default) or `"hidden"`. **Result** — a LocatorResult; `resolvedBy` and `bounds` are absent for `"hidden"`. ### `Locator.boundingBox` Wait for the target, then return where it is. ```json theme={null} { "id": 14, "method": "Locator.boundingBox", "params": { "locator": { "text": "Photo" } } } ``` **Result** — a LocatorResult. ### `Locator.text` Wait for the target, then return its recognized text. ```json theme={null} { "id": 15, "method": "Locator.text", "params": { "locator": { "query": "the order total" } } } ``` **Result** — a LocatorResult plus `text` (string). ### `Locator.count` Count the matches on the current screen, zero included. Never waits. ```json theme={null} { "id": 16, "method": "Locator.count", "params": { "locator": { "text": "Add to cart" } } } ``` **Result** — `count` (integer), `resolvedBy`, and `tookMs`. Customer-billed cost is available from the session's [retained Telemetry response](/telemetry/retrieve), not from the DCP result. ## Errors A failed command answers with `error` instead of `result`: ```json theme={null} { "id": 3, "error": { "code": -32602, "message": "x is required", "data": { "kind": "InvalidArgs", "retryable": false } } } ``` `data.kind` is the machine-readable classification and `data.retryable` says whether retrying the same command could succeed: | Kind | Code | Retryable | Meaning | | - | - | - | - | | `UnknownOp` | -32601 | No | The phone doesn't recognize the method. | | `InvalidArgs` | -32602 | No | The params failed validation. | | `Internal` | -32603 | No | An unclassified server-side failure. | | `NoAllocation` | -32001 | No | The session has no active phone allocation. | | `NotConnected` | -32002 | No | The server couldn't reach the phone. | | `DeviceOffline` | -32004 | Yes | The phone is transiently unavailable. | | `Timeout` | -32006 | Yes | The command exceeded its deadline. | | `Unauthorized` | -32007 | No | The session credential was rejected. | | `Canceled` | -32008 | No | The command was canceled. | | `ActionTimeout` | -32009 | No | A Locator target never became ready within `timeoutMs`. | | `StrategyUnavailable` | -32010 | No | The locator needs a resolver this session doesn't have, such as the accessibility tree. | ## Next steps > [card] [Drive a phone with raw DCP](/api-reference/dcp/raw-usage) > > A worked example, socket to first command. > [card] [Protocol overview](/api-reference/dcp/overview) > > How the wire is framed and why. > [card] [Reconnect and resume](/api-reference/dcp/reconnect) > > Close classes, cursor replay, resync, and idempotency keys. > [card] [Use the SDK instead](/driver/reference) > > The ergonomic driver over these methods. > [card] [Vision models](/drivers/models) > > Model ids and pricing for Locator descriptions. # Locate Element Source: https://docs.axilio.ai/api-reference/elements/locate-element /api-reference/openapi-argus.json post /api/v1/elements:locate Find the element a query describes among the given UI elements. Answers with one of the request's element ids, or not found; never a point. # List Models Source: https://docs.axilio.ai/api-reference/models/list-models /api-reference/openapi-argus.json get /api/v1/models List every model Argus supports, with pricing. Two families: the curated VLMs served by the locate methods (per-token pricing; pass their id as `model`) and the Axilio model line behind screenshots:detect (per-page pricing; selected via `ocr_engine` / `inference_type` — lite is the free engine, pro is premium). Public: no API key required (it's a catalog of model names and public prices, nothing sensitive), so a client can discover supported models before it holds credentials. # API Reference Source: https://docs.axilio.ai/api-reference/overview Use the Axilio REST API for phones, agents, runs, Files, Telemetry, usage, billing, and organization resources. The REST reference is generated from Axilio's public OpenAPI contract. It covers the customer-facing operations available through `api.axilio.ai`. Each endpoint shows its parameters, request and response schemas, and a **Try it** panel. Use the [Python SDK](/quickstart#python), [Go SDK](/quickstart#go), or [CLI](/cli) when you want a typed or interactive interface. Use the REST API from another runtime or when you need direct control over HTTP requests. ## Base URLs | API | Base URL | | - | - | | REST API | `https://api.axilio.ai/api/v1` | | Argus API ([models](/drivers/models)) | `https://argus.axilio.ai/api/v1` | ## Authentication Send your API key in the `X-Axilio-Api-Key` header: ```bash theme={null} curl https://api.axilio.ai/api/v1/phones/availability \ -H "X-Axilio-Api-Key: axl_your_key_here" ``` Get a key from [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys). See [Authentication](/authentication) for the SDK equivalent. ### Run the curl examples on Windows The REST examples use Bash syntax. Git Bash and WSL run them unchanged. To run them in PowerShell: * Call `curl.exe`, which ships with Windows 10 and 11. In Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. * Write environment variables as `$env:AXILIO_API_KEY`. * End a continued line with a backtick (`` ` ``) instead of `\`. * Windows PowerShell 5.1 strips the double quotes inside a single-quoted JSON argument. Save the body to a file and pass `--data '@body.json'`, or use PowerShell 7.3 or later, which passes it unchanged. ```powershell theme={null} curl.exe https://api.axilio.ai/api/v1/phones/availability ` -H "X-Axilio-Api-Key: $env:AXILIO_API_KEY" ``` ## REST resources | Resource | What you can do | Reference | | - | - | - | | **Phones and sessions** | Check availability, allocate and release phones, inspect sessions, manage phones, and mint live-view or Telemetry stream URLs. | [List phones](/api-reference/rest/phones/list-the-orgs-phones) · [Check availability](/api-reference/rest/phones/phone-availability-summary) | | **Agents and runs** | Create and revise agents, schedule them, start ad hoc or saved runs, pause, answer and follow up, stream a run, read its steps and script, cancel runs, and read run metrics. | [List agents](/api-reference/rest/agents/list-agents) · [Start a run](/api-reference/rest/runs/start-a-run) | | **Files** | Upload and manage organization library files, deliver a file to a phone, and list files captured during a session. | [List files](/api-reference/rest/files/list-the-orgs-files) · [List session files](/api-reference/rest/files/list-a-sessions-captured-files) | | **Telemetry** | Read a session's Telemetry frames or follow its live event stream. | [List frames](/api-reference/rest/runs/list-a-sessions-telemetry-frames) · [Stream telemetry](/api-reference/rest/phones/stream-a-sessions-telemetry) | | **Usage and billing** | Read phone-session and inference usage, account balance, invoices, plan details, and billing preferences. Admins can update auto-recharge and usage-alert settings. | [List session usage](/api-reference/rest/usage/list-phone-sessions-with-cost) · [Get balance](/api-reference/rest/billing/get-account-balance) | | **Organization and API keys** | Read organization and member details. Admins can view pending invitations and create, list, or delete API keys. | [Get organization](/api-reference/rest/organization/get-the-callers-organization) · [List API keys](/api-reference/rest/api-keys/list-api-keys) | > [!NOTE] > > **Files** is the resource name. Upload, download, and delivery are actions on > files. Session execution data is exposed through the > [Telemetry frame contract](/telemetry/frames), not events. ## Argus operations Argus is the model service the driver uses to understand a screen. Call it directly to ground a query in your own input. Its operations are split by input, so each request and answer fits its input exactly: | Operation | Input | Answer | | - | - | - | | [Models: list](/api-reference/argus/list-models) | None. Public, no API key. | The model catalog with live pricing. | | [Screenshots: detect](/api-reference/argus/screenshots-detect) | A screenshot. | Every detected icon and text element. | | [Screenshots: locate](/api-reference/argus/screenshots-locate) | A screenshot and a query. | A pixel box, or a match in OCR text you supplied. | | [Elements: locate](/api-reference/argus/elements-locate) | UI elements you already have, a query, and an optional screenshot. | The chosen element's ID, or not found. | Argus uses the same API key header and the same [problem details](/reference/errors#problem-details) error shape as the REST API. ## Roles & permissions Everyone in an organization has one of three roles. The roles are hierarchical, so each role includes the permissions of the roles below it: | Role | Can do | | - | - | | **Viewer** | Read organization, phone, session, agent, run, file, Telemetry, usage, and billing resources. | | **Member** | Everything a viewer can do, plus allocate and manage phones, start, pause, answer or cancel runs, edit agents and schedules, and manage or deliver files. | | **Admin** | Everything a member can do, plus manage API keys, view pending invitations, and update auto-recharge or usage-alert settings. | Authenticated operations declare an `x-required-role` value in the OpenAPI contract. This value is the minimum role, not an exclusive role. `viewer` allows viewers, members, and admins. `member` allows members and admins. `admin` allows admins only. Not sure what you are? Check your role in [**Settings → Members**](https://app.axilio.ai/dashboard/axilio/settings/members). ## Next steps > [card] [SDK quickstart](/quickstart) > > Allocate and control your first phone with the Python SDK. > [card] [Device Control Protocol](/api-reference/dcp/overview) > > Drive an allocated phone over its WebSocket protocol. > [card] [Telemetry](/telemetry/overview) > > Choose a live or retained interface and build a resilient reader. # Add a revision Source: https://docs.axilio.ai/api-reference/rest/agents/add-a-revision /api-reference/openapi-backend.json post /agents/{agent_id}/revisions Appends a revision to an agent, instructions or code, and makes it the one runs use. Revisions are never changed or removed. Minimum role required: Member # Create a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/create-a-schedule /api-reference/openapi-backend.json post /agents/{agent_id}/schedules Creates a schedule of an agent: each occurrence of cron_expression, read in time_zone, starts a run of the agent with trigger scheduled and the schedule's id, built from run_template as POST /runs builds a saved agent's run. A cron expression or time zone that doesn't parse, an expression that never fires, a revision of another agent, an agent with no revision, or a schedule with no occurrence before its end_at is 400. Minimum role required: Member # Create an agent Source: https://docs.axilio.ai/api-reference/rest/agents/create-an-agent /api-reference/openapi-backend.json post /agents Creates an agent: a task for a phone, as instructions an agent works through a model or as code, with the model, output schema and limits its runs use. Sending instructions or code creates the agent's first revision with it; sending source_run_id saves one of your agent runs as the agent, its task or its script as the first revision. A name already used in the organization is 409; an unsupported model, an invalid output schema, both instructions and code, or a run with no task or script to save is 400; another organization's run is 404. Minimum role required: Member # Delete a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/delete-a-schedule /api-reference/openapi-backend.json delete /agents/{agent_id}/schedules/{schedule_id} Deletes a schedule and cancels its runs that have not started. Its other runs keep their schedule_id. Minimum role required: Member # Delete an agent Source: https://docs.axilio.ai/api-reference/rest/agents/delete-an-agent /api-reference/openapi-backend.json delete /agents/{agent_id} Deletes an agent and its revisions. Its runs and sessions keep their history. Minimum role required: Member # Get a revision Source: https://docs.axilio.ai/api-reference/rest/agents/get-a-revision /api-reference/openapi-backend.json get /agents/{agent_id}/revisions/{revision_id} Fetches one of an agent's revisions with its instructions or code. A revision of another agent is 404. Minimum role required: Viewer # Get a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/get-a-schedule /api-reference/openapi-backend.json get /agents/{agent_id}/schedules/{schedule_id} Fetches one of an agent's schedules. Another agent's is 404. Minimum role required: Viewer # Get an agent Source: https://docs.axilio.ai/api-reference/rest/agents/get-an-agent /api-reference/openapi-backend.json get /agents/{agent_id} Fetches one agent. A missing or other-organization id is 404. Minimum role required: Viewer # List agents Source: https://docs.axilio.ai/api-reference/rest/agents/list-agents /api-reference/openapi-backend.json get /agents Lists the organization's agents, newest first, one page at a time. Minimum role required: Viewer # List an agent's revisions Source: https://docs.axilio.ai/api-reference/rest/agents/list-an-agents-revisions /api-reference/openapi-backend.json get /agents/{agent_id}/revisions Lists an agent's revisions, newest first, one page at a time. Minimum role required: Viewer # List schedules Source: https://docs.axilio.ai/api-reference/rest/agents/list-schedules /api-reference/openapi-backend.json get /agents/{agent_id}/schedules Lists an agent's schedules, or with agent_id - the schedules of every agent, newest first, one page at a time. Minimum role required: Viewer # Pause a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/pause-a-schedule /api-reference/openapi-backend.json post /agents/{agent_id}/schedules/{schedule_id}:pause Stops a schedule's occurrences and cancels its runs that have not started. A run already running goes on. A schedule that is not active is 409. Minimum role required: Member # Promote a proposed fix Source: https://docs.axilio.ai/api-reference/rest/agents/promote-a-proposed-fix /api-reference/openapi-backend.json post /agents/{agent_id}/revisions/{revision_id}:promote Makes a fix a repair proposed the agent's current revision. 409 when the revision is not a proposed fix waiting to be promoted, or when the agent has moved on from the revision the repaired run ran. Minimum role required: Member # Resume a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/resume-a-schedule /api-reference/openapi-backend.json post /agents/{agent_id}/schedules/{schedule_id}:resume Makes a paused schedule active again from its next occurrence after now; occurrences missed while paused do not run. A schedule that is not paused, or has no occurrence left before its end_at, is 409. Minimum role required: Member # Roll an agent back to an earlier revision Source: https://docs.axilio.ai/api-reference/rest/agents/roll-an-agent-back-to-an-earlier-revision /api-reference/openapi-backend.json post /agents/{agent_id}:rollback Copies an earlier revision forward as the agent's newest revision, which becomes the one runs use. History only grows: nothing is removed. Minimum role required: Member # Run a schedule now Source: https://docs.axilio.ai/api-reference/rest/agents/run-a-schedule-now /api-reference/openapi-backend.json post /agents/{agent_id}/schedules/{schedule_id}:run Starts one run of the schedule's template now, whatever the schedule's state and overlap policy, as POST /runs starts a saved agent's run. The run carries the schedule's id; the schedule's occurrences are unchanged. While your organization is at its concurrency limit, the run is refused with 409. Minimum role required: Member # Update a schedule Source: https://docs.axilio.ai/api-reference/rest/agents/update-a-schedule /api-reference/openapi-backend.json patch /agents/{agent_id}/schedules/{schedule_id} Changes a schedule's name, timing, run template or overlap policy; omitted fields are left unchanged. An active schedule whose timing changed takes its next occurrence from now. Its state changes through :pause and :resume. Minimum role required: Member # Update an agent Source: https://docs.axilio.ai/api-reference/rest/agents/update-an-agent /api-reference/openapi-backend.json patch /agents/{agent_id} Changes an agent's name, model, output schema, limits or data toggles; omitted fields are left unchanged. Its revisions change through POST /agents/{agent_id}/revisions. Minimum role required: Member # Create an API key Source: https://docs.axilio.ai/api-reference/rest/api-keys/create-an-api-key /api-reference/openapi-backend.json post /api-keys Mints a fresh API key for the caller's organization. The plaintext key value is returned exactly once and never stored or returned again. Minimum role required: Admin # Delete an API key Source: https://docs.axilio.ai/api-reference/rest/api-keys/delete-an-api-key /api-reference/openapi-backend.json delete /api-keys/{key_id} Revokes an API key. Subsequent requests using its value are rejected as unauthorized. Minimum role required: Admin # List API keys Source: https://docs.axilio.ai/api-reference/rest/api-keys/list-api-keys /api-reference/openapi-backend.json get /api-keys Lists the API keys for the caller's organization. Filter by search (name or key preview), created_by_id and a created_after/created_before window; order with order_by ('<field> <asc|desc>'), field one of name, created_by_id, created_at or last_used_at. Defaults to created_at desc. Minimum role required: Admin # Get account balance Source: https://docs.axilio.ai/api-reference/rest/billing/get-account-balance /api-reference/openapi-backend.json get /billing/balance Returns the organization's current credit balance in microdollars plus a display string. Minimum role required: Viewer # Get an invoice PDF URL Source: https://docs.axilio.ai/api-reference/rest/billing/get-an-invoice-pdf-url /api-reference/openapi-backend.json get /billing/history/{invoice_id}/pdf Returns a temporary PDF download URL for an invoice the caller's org owns. The URL expires; re-request it rather than storing it. Minimum role required: Viewer # Get auto-recharge settings Source: https://docs.axilio.ai/api-reference/rest/billing/get-auto-recharge-settings /api-reference/openapi-backend.json get /billing/auto-recharge Returns the organization's automatic balance top-up configuration and status. Minimum role required: Viewer # Get balance statement Source: https://docs.axilio.ai/api-reference/rest/billing/get-balance-statement /api-reference/openapi-backend.json get /billing/balance/statement The organization's balance over a period, like a bank statement: the starting balance, credit added, what each product used, credit taken back by refunds and chargebacks, credit that expired, and the ending balance. Minimum role required: Viewer # Get current plan Source: https://docs.axilio.ai/api-reference/rest/billing/get-current-plan /api-reference/openapi-backend.json get /billing/subscription Returns the caller organization's active subscription plan. Only an active subscription is returned; if the org has none, this responds 404. (The status field is therefore always 'active' here.) Minimum role required: Viewer # Get usage-alert settings Source: https://docs.axilio.ai/api-reference/rest/billing/get-usage-alert-settings /api-reference/openapi-backend.json get /billing/usage-alerts Returns the organization's balance-alert configuration and any currently open alerts (low balance, negative balance). Minimum role required: Viewer # List balance transactions Source: https://docs.axilio.ai/api-reference/rest/billing/list-balance-transactions /api-reference/openapi-backend.json get /billing/balance/transactions The organization's balance history: every movement of its balance (usage, credit added or granted, expiry, refunds and disputes), newest first, with optional filters. Minimum role required: Viewer # List billing history Source: https://docs.axilio.ai/api-reference/rest/billing/list-billing-history /api-reference/openapi-backend.json get /billing/history Paginated invoice history for the caller's organization with optional filters and sort. Minimum role required: Viewer # List phone rentals Source: https://docs.axilio.ai/api-reference/rest/billing/list-phone-rentals /api-reference/openapi-backend.json get /billing/phone-rental-subscriptions Returns one page of the caller organization's phone rental subscriptions, in any lifecycle state unless status filters it (canceled rentals stay listed so past rentals remain visible). Filter by search, status, plan_interval, cancel_at_period_end, payment_status and a current_period_end_after/current_period_end_before window; order with order_by ('<field> <asc|desc>'). The summary block (active_count, combined monthly cost, per-interval breakdown, upcoming charges) covers every active rental and ignores the filters. Minimum role required: Viewer # Update auto-recharge settings Source: https://docs.axilio.ai/api-reference/rest/billing/update-auto-recharge-settings /api-reference/openapi-backend.json patch /billing/auto-recharge Configures automatic balance top-up: when the balance drops below the threshold, a one-off invoice charges the saved payment method to restore it to the target. Requires an admin-role caller: an API key carries its creator's organization role, so the key must belong to an org admin. This only tunes when the saved payment method is charged — adding funds or payment methods stays in the dashboard. Minimum role required: Admin # Update usage-alert settings Source: https://docs.axilio.ai/api-reference/rest/billing/update-usage-alert-settings /api-reference/openapi-backend.json patch /billing/usage-alerts Configures the low-balance alert: while enabled and the balance sits below the threshold, an alert stays open and surfaces in the dashboard. Negative-balance alerts are always on. Requires an admin-role caller: an API key carries its creator's organization role, so the key must belong to an org admin. Minimum role required: Admin # Delete a conversation Source: https://docs.axilio.ai/api-reference/rest/conversations/delete-a-conversation /api-reference/openapi-backend.json delete /conversations/{conversation_id} Deletes every message in a conversation, as DELETE /messages/{message_id} does for one: the conversation and its messages are removed from every list at once, attachments within 48 hours, and the phone is asked to delete the conversation's messages on its side up to the newest message deleted here (anything it holds that is newer, including texts it has not passed on yet, stays). The response returns at once, with phone_deletion.status pending while the phone has not answered; Axilio keeps checking for up to 7 days, asking the phone again until it confirms (phone_deletion.next_check_at is the next check), and a phone not yet set up to delete messages (not_default_app) is asked once it can. GET /messages/{message_id} on any of the deleted messages shows where the phone's copy stands, and each message fires message.deleted, then message.phone_deleted or message.phone_delete_failed, as DELETE /messages/{message_id} describes. Deleting a conversation does not stop a message in it the phone is still sending. A conversation with no messages left is a 404. Minimum role required: Member # Get a conversation Source: https://docs.axilio.ai/api-reference/rest/conversations/get-a-conversation /api-reference/openapi-backend.json get /conversations/{conversation_id} Returns one conversation: its phone, its participants and its latest message. Another organization's conversation reads as not found. Minimum role required: Viewer # List a conversation's messages Source: https://docs.axilio.ai/api-reference/rest/conversations/list-a-conversations-messages /api-reference/openapi-backend.json get /conversations/{conversation_id}/messages Returns one page of a conversation's messages, both directions, newest first by default (order_by created_at asc reads it oldest first, as a thread shows it). Each message has its full body. Minimum role required: Viewer # List conversations Source: https://docs.axilio.ai/api-reference/rest/conversations/list-conversations /api-reference/openapi-backend.json get /conversations Returns one page of your dedicated phones' conversations, newest activity first: one per phone and set of people it texts with, each with its participants and latest message. Filter by phone (phone_id, or phone_number, the phone's own number), and search the participants with search, which matches any part of a person's number with formatting ignored. A text sent to a number and the replies from it are one conversation; the same number on two of your phones is two. Filter by what became of your texts with status, error_code and replied, optionally within a period (period_start_at, period_end_at): they look only at texts you sent in that period (the 7 days before period_end_at when the start is unset, the same window rules as GET /messages/metrics), combine with each other and with the other filters, and list a conversation when it passes every one. status and error_code match a conversation with at least one such text, so they list conversations, not texts: GET /messages/metrics counts both, in totals (texts) and conversations.by_outcome and failures[].conversations (conversations). With only a period, it lists the conversations you texted in it. Minimum role required: Viewer # Reply in a conversation Source: https://docs.axilio.ai/api-reference/rest/conversations/reply-in-a-conversation /api-reference/openapi-backend.json post /conversations/{conversation_id}/messages Sends a message into a conversation, from its phone to its participant: the same send as POST /messages, with the same limits, idempotency and webhooks, and the same 202 response: the message, queued. A conversation with more than one other person, or whose other party is a short code or an unknown sender, can't take a reply yet (422). Minimum role required: Member # Delete a file Source: https://docs.axilio.ai/api-reference/rest/files/delete-a-file /api-reference/openapi-backend.json delete /files/{file_id} Removes a file from the org's library: the stored object and the library entry go immediately. Copies already on phones are not removed, whether the file was pushed to them or captured from one; removing a file from a phone is a separate operation. This runs the same for an uploaded or a captured file. Minimum role required: Member # Get a file Source: https://docs.axilio.ai/api-reference/rest/files/get-a-file /api-reference/openapi-backend.json get /files/{file_id} Returns one file from the org's library, with a short-lived read URL once its bytes are ready. A message's media part names its file by id; this is where that id resolves. Another org's file id reads as not found. Minimum role required: Viewer # List a session's captured files Source: https://docs.axilio.ai/api-reference/rest/files/list-a-sessions-captured-files /api-reference/openapi-backend.json get /phones/sessions/{session_id}/files Returns the files this session captured off its phone, newest first — the direct answer to "what did this session capture". Every row is source=capture. Rows appear at detection, before the bytes finish moving, so a caller waiting on a file watches its capture state progress rather than an empty list. Minimum role required: Viewer # List the org's files Source: https://docs.axilio.ai/api-reference/rest/files/list-the-orgs-files /api-reference/openapi-backend.json get /files Returns one page of the org's file library, newest first, with the org's standing usage against its storage quota. Every file carries its source (upload, capture or message) and, for a capture or message attachment, its surface and capture state. Filter with source, surface, session_id, phone_id and media_type, among others. Files persist until deleted and any ready file can be delivered to a phone the org holds. Minimum role required: Viewer # Mark an upload complete Source: https://docs.axilio.ai/api-reference/rest/files/mark-an-upload-complete /api-reference/openapi-backend.json post /files/{file_id}:complete Call after PUTting the bytes to the upload URL. Verifies the object landed at the declared size and type, checks the content really is the media it claims to be, and moves the file to ready so it can be delivered. Idempotent: completing an already-ready file just returns it. Applies to source=upload files; a captured file finalizes on its own from the phone's report. Minimum role required: Member # Register a file upload Source: https://docs.axilio.ai/api-reference/rest/files/register-a-file-upload /api-reference/openapi-backend.json post /files Registers an image or video in the org's file library and returns a presigned S3 URL to upload the bytes to. PUT the raw file to upload_url with the declared Content-Type and Content-Length headers before the URL expires, then call the complete endpoint to make it ready. The registered file has source=upload. Uploads are capped per file and per org by total size. Minimum role required: Member # Update a file Source: https://docs.axilio.ai/api-reference/rest/files/update-a-file /api-reference/openapi-backend.json patch /files/{file_id} Updates the file's display name. Metadata only: storage is keyed by id, so the object never moves and existing URLs keep working, and past deliveries keep the name they were sent under. Only an uploaded file can be renamed; a captured file's name is part of its provenance, so renaming one is rejected. Minimum role required: Member # Delete a message Source: https://docs.axilio.ai/api-reference/rest/messages/delete-a-message /api-reference/openapi-backend.json delete /messages/{message_id} Deletes a message: it is removed from every list and conversation at once, its attachments are removed from your file library within 48 hours, and the delete is recorded with who made it. The phone is also asked to delete its own copy, asynchronously. The response returns at once with the deleted message as GET /messages/{message_id} returns it (deleted_at set, no body or parts), with phone_deletion.status pending while the phone has not answered. Axilio keeps checking for up to 7 days, asking the phone again until it confirms; phone_deletion.next_check_at is when it next checks (null once the outcome is final). A phone not yet set up to delete messages (not_default_app) is asked once it can, within those 7 days. GET /messages/{message_id} keeps returning the deleted message and shows where the phone's copy stands in phone_deletion. Webhooks: message.deleted fires at once; message.phone_deleted fires when the phone holds no copy, and message.phone_delete_failed when it is final that the copy is still there or may be (ambiguous, not_matched, not_sent, or unconfirmed after the 7 days). A message the phone stored before it became the phone's messaging app can only be deleted on the phone when it is a plain text the phone can match, and only on the first attempt; otherwise the phone answers not_matched. Deleting a message the phone is still sending does not stop the send. Deleting a message that does not exist or is already deleted is a 404. Minimum role required: Member # Get a message Source: https://docs.axilio.ai/api-reference/rest/messages/get-a-message /api-reference/openapi-backend.json get /messages/{message_id} Returns one text message a dedicated phone received or sent, with its full body. A sent message carries its delivery status. Minimum role required: Viewer # Get message metrics Source: https://docs.axilio.ai/api-reference/rest/messages/get-message-metrics /api-reference/openapi-backend.json get /messages/metrics Returns your texting over a window (the last 7 days by default, starting at most a year back): texts sent and received, how the sent ones ended up (delivered, left the phone with no delivery report, unconfirmed, failed, still queued) and the delivery rate, how long they took to send and to be delivered, why they failed, your conversations and how many of the ones you texted wrote back within a day, and each phone's share with its busiest 30 minutes, to compare with the send_limit the response carries. The points chart it by the hour or the day (granularity) in time_zone; hour buckets need a window of 7 days or less. Scope it to one phone with phone_id or one conversation with conversation_id. A text counts at the time the platform stored it when sent, and when the phone received it when received. Minimum role required: Viewer # List messages Source: https://docs.axilio.ai/api-reference/rest/messages/list-messages /api-reference/openapi-backend.json get /messages Returns one page of every text message your dedicated phones have received and sent, across conversations, newest first, each with its full body and its conversation_id; direction tells them apart. Filter by phone_id, direction and when the platform stored the message (created_after, created_before), for example to collect everything since your last read. To read a conversation, use GET /conversations/{conversation_id}/messages. Only dedicated phones with a current rental receive messages; texts arriving on shared phones, or after a rental lapses, are never stored. Minimum role required: Viewer # Send a message Source: https://docs.axilio.ai/api-reference/rest/messages/send-a-message /api-reference/openapi-backend.json post /messages Sends a message from one of your dedicated phones: an SMS when parts is one text part, an MMS when it carries media from your file library. The phone must have outbound messaging enabled by your administrator. Returns 202 with the message, queued; the outcome arrives as a message.sent or message.failed webhook and is readable at GET /messages/{message_id}. A phone that never received the message within about 15 minutes (it is offline, say) fails it rather than sending it late; one that received it but has not reported back by then makes it unconfirmed until it does. Each phone sends at most 15 messages per 30 minutes. Reusing an idempotency_key returns the original message, as it is now, instead of sending again; reusing it for a different message is a 409. Minimum role required: Member # List models Source: https://docs.axilio.ai/api-reference/rest/models/list-models /api-reference/openapi-backend.json get /models Lists the models an agent's runs can call: supported models that see the screen and call tools, by display name. An agent's model field takes one of their slugs. Minimum role required: Viewer # Get the caller's organization Source: https://docs.axilio.ai/api-reference/rest/organization/get-the-callers-organization /api-reference/openapi-backend.json get /organization Returns a descriptor of the organization the API key belongs to: name, slug, id, active plan name. Organization management (rename, members, invitations) is deliberately not part of the public API: it is dashboard-only so an API key can never modify the organization that issued it. Minimum role required: Viewer # List pending invitations Source: https://docs.axilio.ai/api-reference/rest/organization/list-pending-invitations /api-reference/openapi-backend.json get /organization/invitations Returns every outstanding invitation for the caller's organization. Requires the admin role, matching the dashboard. Organization management (rename, members, invitations) is deliberately not part of the public API: it is dashboard-only so an API key can never modify the organization that issued it. Minimum role required: Admin # List the organization's members Source: https://docs.axilio.ai/api-reference/rest/organization/list-the-organizations-members /api-reference/openapi-backend.json get /organization/members Returns every member of the caller's organization with email, role, and join date. Organization management (rename, members, invitations) is deliberately not part of the public API: it is dashboard-only so an API key can never modify the organization that issued it. Minimum role required: Viewer # Allocate a phone Source: https://docs.axilio.ai/api-reference/rest/phones/allocate-a-phone /api-reference/openapi-backend.json post /phones:allocate Allocates an Android phone and opens a session you drive directly. Pass phone_id to pin a specific dedicated phone. If allocation setup fails the claim is rolled back, so you are never billed for a session that never starts. Minimum role required: Member # Deliver a library file to a phone Source: https://docs.axilio.ai/api-reference/rest/phones/deliver-a-library-file-to-a-phone /api-reference/openapi-backend.json post /phones/{phone_id}/deliveries Sends a library file to a phone the caller's org holds: the phone downloads it over its own connection and inserts it into the media gallery, where app pickers can select it. Accepts any file by id regardless of source (uploaded or captured), and the file must already be ready - finish an uploaded file with POST /files/{file_id}:complete before delivering it. Returns 202 with the delivery record once the phone acknowledges the download started; watch GET /phones/{phone_id}/deliveries or the live preview for completion. Optionally choose the target collection (DCIM / Pictures / Movies). Minimum role required: Member # Get a phone Source: https://docs.axilio.ai/api-reference/rest/phones/get-a-phone /api-reference/openapi-backend.json get /phones/{phone_id} Returns a single phone by its identifier. Minimum role required: Viewer # Get a phone's live screen preview Source: https://docs.axilio.ai/api-reference/rest/phones/get-a-phones-live-screen-preview /api-reference/openapi-backend.json get /phones/{phone_id}/preview Returns a short-lived URL for the phone's current screen preview — a rolling JPEG refreshed every few seconds while the phone is paired, available with or without an active session. Poll this endpoint and swap the image; every call mints a fresh URL. Status is "pending" when no preview exists yet. Authorized to the org that owns the phone or currently holds its active session; any other org reads as not found. Minimum role required: Viewer # Get a session Source: https://docs.axilio.ai/api-reference/rest/phones/get-a-session /api-reference/openapi-backend.json get /phones/sessions/{session_id} Returns one session for the Session Inspector: session lifecycle, phone display fields, the agent's name (for an agent's run) and an inlined presigned recording URL. Works for active and terminal sessions, and for agents' runs and interactive leases. Org-scoped: another org's session reads as not found. Minimum role required: Viewer # Get a session recording Source: https://docs.axilio.ai/api-reference/rest/phones/get-a-session-recording /api-reference/openapi-backend.json get /phones/sessions/{session_id}/recording Returns a short-lived URL for the session's screen recording, keyed on session_id — so it works for agents' runs and interactive leases alike. Status is "pending" (no URL) when the recording hasn't finished uploading yet, and "off" when the session was not recorded. Org-scoped: another org's session reads as not found. Minimum role required: Viewer # Get one file delivery Source: https://docs.axilio.ai/api-reference/rest/phones/get-one-file-delivery /api-reference/openapi-backend.json get /phones/{phone_id}/deliveries/{delivery_id} Returns a single delivery by id and its current status. Poll this to wait on a specific push: the list endpoint pages the newest records and can drop a delivery that ages past the page on a busy phone. Minimum role required: Viewer # List a phone's file deliveries Source: https://docs.axilio.ai/api-reference/rest/phones/list-a-phones-file-deliveries /api-reference/openapi-backend.json get /phones/{phone_id}/deliveries Returns the phone's file delivery records, newest first: which library files were sent to it and where each stands (dispatching / dispatched / delivered / failed). Org-scoped: another org's phone reads as not found. Minimum role required: Viewer # List sessions Source: https://docs.axilio.ai/api-reference/rest/phones/list-sessions /api-reference/openapi-backend.json get /phones/sessions Returns one page of the org's phone sessions for the Session Inspector table: active/unbilled sessions pinned on top, terminal history paginated beneath. Covers agents' runs and interactive leases; each row links to a session. Filters: search, agent_id, status. Minimum role required: Viewer # List supported apps Source: https://docs.axilio.ai/api-reference/rest/phones/list-supported-apps /api-reference/openapi-backend.json get /phones/supported-apps Returns the apps the platform supports orchestration for, optionally filtered by platform and category. Minimum role required: Viewer # List the org's phones Source: https://docs.axilio.ai/api-reference/rest/phones/list-the-orgs-phones /api-reference/openapi-backend.json get /phones Returns the caller org's full dedicated (private/rented) phone inventory - every state, not just the free ones: busy phones in an active session, offline/inactive phones, and phones in maintenance are all included, so this is the endpoint to discover a phone_id you can pin via POST /phones:allocate. Each phone's current_session_id and status reflect its live state. include_expired=true also keeps rentals past their rental_expires_at so users can see what they used to own. Filter by status/phone_type and paginate; the response total is the full match count. Pass ownership=dedicated. Minimum role required: Viewer # Mint a live-view token Source: https://docs.axilio.ai/api-reference/rest/phones/mint-a-live-view-token /api-reference/openapi-backend.json post /phones/sessions/{session_id}:createLiveViewToken Mints a fresh live-view token (and hosted viewer URL) for an active session, per the session's allocate-time live_view settings: token-mode sessions re-issue the bearer link allocate returned once, org-mode sessions exchange the caller's identity. The token is view-only when the session was allocated with live_view.view_only or the caller has the viewer role; only members and admins get a token that can drive the phone. Refused for sessions allocated with live_view.disabled, for inactive sessions, and for sessions outside the caller's organization (reads as not found). Sharp edge: the returned URL embeds the token and is a bearer capability — whoever holds it can watch (and, unless the token is view-only, drive) the phone until the session ends, at which point both stop working. Minimum role required: Viewer # Phone availability summary Source: https://docs.axilio.ai/api-reference/rest/phones/phone-availability-summary /api-reference/openapi-backend.json get /phones/availability Returns a capacity snapshot to check before POST /phones:allocate: shared-pool availability broken down by phone type and by location, plus the caller org's dedicated phones with how many are idle (claimable right now). Optional phone_type and location filters narrow every count. Advisory only - availability can change between this read and an allocate, so allocation remains the authority and can still refuse. Minimum role required: Viewer # Release a phone Source: https://docs.axilio.ai/api-reference/rest/phones/release-a-phone /api-reference/openapi-backend.json post /phones/{phone_id}:deallocate Deallocates a phone the caller's org currently holds. The session is billed and the phone is torn down asynchronously. Pass session_id to end that session only: if the phone has already moved on to another session, nothing is released and the call still succeeds. Minimum role required: Member # Stream a session's telemetry Source: https://docs.axilio.ai/api-reference/rest/phones/stream-a-sessions-telemetry /api-reference/openapi-backend.json get /realtime/sessions/{session_id}/events Streams a session's telemetry as Server-Sent Events (text/event-stream): every frame of the canonical envelope GET /phones/sessions/{session_id}/frames returns, live as the session runs. Each event's id is its resume cursor, its event name is the frame kind (span or log), and its data is the frame JSON; live spans arrive as a start frame and then an end frame. A new reader (or one whose cursor is older than the live window) first receives the archived trace and then the live frames, deduplicated on span id and phase, followed by an event with no data that carries the cursor the replay ended at. Reconnect with the Last-Event-ID header (EventSource sends it on its own) to resume with no gap. A comment line is sent every 15 seconds while the stream is idle. After the session ends the stream serves what is left and closes; a reconnect with nothing left after its cursor gets 204, which tells EventSource to stop. Authenticate with the API key header, or open the session's telemetry_url, which carries a read-only token for this one session (for browsers, which cannot set headers on an EventSource). A session started with telemetry off streams nothing (409). Org-scoped: another org's session reads as not found. The stream is served by the realtime service: telemetry_url points at its host, and this path on the API host is routed there too. Each event carries exactly one frame. Readers MUST ignore events with an unknown name, unknown fields, and unknown span_type or log_type values. Minimum role required: Viewer # Update a phone Source: https://docs.axilio.ai/api-reference/rest/phones/update-a-phone /api-reference/openapi-backend.json patch /phones/{phone_id} Changes the fields a customer may set on a dedicated phone the caller's org owns: its nickname. Returns the updated phone. Minimum role required: Member # Wipe a phone (not available) Source: https://docs.axilio.ai/api-reference/rest/phones/wipe-a-phone-not-available /api-reference/openapi-backend.json post /phones/{phone_id}:wipe Not available right now: always answers 409 and leaves the phone unchanged. Minimum role required: Member # Cancel a run Source: https://docs.axilio.ai/api-reference/rest/runs/cancel-a-run /api-reference/openapi-backend.json post /runs/{run_id}:cancel Cancels a run that is scheduled, queued, running or paused, scoped to the caller's org. A scheduled or queued run is cancelled before it starts and never runs. A running or paused run is stopped on its phone; an agent-mode run then keeps its phone for keep_alive_seconds, as when a turn ends, so :sendMessage can follow up on the same phone until keep_alive_expires_at. A run that has already reached a terminal state (completed/failed/cancelled) cannot be cancelled and reads as not found. Returns the updated run. Minimum role required: Member # Get a run Source: https://docs.axilio.ai/api-reference/rest/runs/get-a-run /api-reference/openapi-backend.json get /runs/{run_id} Returns one run by ID, scoped to the caller's organization. Minimum role required: Viewer # Get a run's script Source: https://docs.axilio.ai/api-reference/rest/runs/get-a-runs-script /api-reference/openapi-backend.json get /runs/{run_id}/script Returns every cell the run executed, in order, as one Python file and one by one, failed and exploratory cells included. A code run's first cell is its revision's script. The script is kept with the run's data, so it outlives the transcript; when that record is missing, the cells are rebuilt from what remains of the transcript and partial is true. 404 when there is nothing to build it from. Minimum role required: Viewer # Get run metrics Source: https://docs.axilio.ai/api-reference/rest/runs/get-run-metrics /api-reference/openapi-backend.json get /runs/metrics Returns your organization's finished runs over a window as points: one per hour or day in time_zone (and per group with group_by), each with the runs by how they ended, the success rate, the median and 95th percentile duration, what they cost and the model steps they took. A run counts in the bucket it finished in; runs still going are not counted. The window defaults to the last 7 days and may start at most a year back; hour buckets need a window of 7 days or less. Minimum role required: Viewer # List a run's events Source: https://docs.axilio.ai/api-reference/rest/runs/list-a-runs-events /api-reference/openapi-backend.json get /runs/{run_id}/events Lists an agent run's events as pages, oldest first: the frames its stream sends (its spans, the model's text and the run's own run_status, run_input_required, run_resumed, run_result and user_message logs) in time order, for a caller that polls rather than holds the stream open. A live run's page always has a next_cursor to poll from; an ended run's last page closes with its final run_status, as the run itself has it, and has none. Pages never repeat a frame, except that a cursor older than the stream's 10-minute window starts again from the run's first event, so a reader that keeps one deduplicates on span_id and phase, as a stream reader does across reconnects. A page keeps a stream entry's frames together, so it can hold a few more than limit. The run's own events and the model's text are live only: past the stream's 10-minute window a page has the archived spans and the closing run_status but no run_result, and a run with telemetry off has only its events while they are live. The run itself always has its result. Minimum role required: Viewer # List a run's steps Source: https://docs.axilio.ai/api-reference/rest/runs/list-a-runs-steps /api-reference/openapi-backend.json get /runs/{run_id}/steps Lists a run's model steps from its transcript, oldest first: the messages each step sent, the model's answer, links to its screenshots and its cost. Empty once the transcript has passed its retention, or when your organization keeps none. Minimum role required: Viewer # List a session's telemetry frames Source: https://docs.axilio.ai/api-reference/rest/runs/list-a-sessions-telemetry-frames /api-reference/openapi-backend.json get /phones/sessions/{session_id}/frames Returns the paginated telemetry frames for a session in the canonical frame envelope: one completed span frame per durable span (operations that never completed appear via their synthesized failed closures) plus log frames, ordered by span start / log time, with response-level billed-cost maps. This is the same envelope the live telemetry event stream (GET /realtime/sessions/{session_id}/events) carries; live and archive differ only in cardinality (start+end frames live, one completed frame here). Org-scoped: another org's session reads as not found. A trace past the organization's telemetry retention window returns an empty list with retention_expired=true; when the retention policy itself cannot be resolved the request fails with a 500 rather than serving frames whose retention state is unknown. Forward-compatible raw JSON consumers should accept unknown fields within known kinds and treat unknown span_type/log_type values generically. Top-level unknown-kind handling varies by interface and SDK version; do not assume every generated SDK exposes an UnknownFrame variant. Live readers should accept either a single frame object or an array for forward compatibility; current first-party forwarding normally sends one frame object per message. Minimum role required: Viewer # List runs Source: https://docs.axilio.ai/api-reference/rest/runs/list-runs /api-reference/openapi-backend.json get /runs Returns paginated recent (non-archived) runs across the caller's organization, every member's runs included. Filters: agent_id, search (run ID substring), status, trigger. Order with order_by ('<field> <asc|desc>'), field one of status, started_at, completed_at, created_at. Minimum role required: Viewer # Pause a run Source: https://docs.axilio.ai/api-reference/rest/runs/pause-a-run /api-reference/openapi-backend.json post /runs/{run_id}:pause Asks a running run to pause for a person, with reason caller_requested. An agent-mode run pauses at its next model step: a cell already running finishes first, and a run about to finish takes that step instead. A code run pauses at its script's next phone action. Returns the run as it is: it reads paused, with its input_request, once the pause lands, and run.paused fires then. A run that is not running is 409. Minimum role required: Member # Resume a paused run Source: https://docs.axilio.ai/api-reference/rest/runs/resume-a-paused-run /api-reference/openapi-backend.json post /runs/{run_id}:resume Resumes a paused run. action answer (the default) answers its input request with response, which must satisfy the request's response_schema (422 when it does not). action retry runs a code run's failed script again (only for reason step_check_failed). action complete ends the run as completed with output, which must satisfy the run's output schema (422). Returns the run as it is. With answer or retry it reads running once the run's phone has the input, and run.resumed fires then; with complete it reads completed once the run ends on its phone, and run.completed fires then. A run that is not paused, a different input_request_id, or a request that has expired is 409. Minimum role required: Member # Send a message to a run Source: https://docs.axilio.ai/api-reference/rest/runs/send-a-message-to-a-run /api-reference/openapi-backend.json post /runs/{run_id}:sendMessage Sends a person's message to an agent-mode run. To a run that is running or paused, the run's model reads it before its next step, as a turn from the person running the task, and the run's stream carries it as a user_message event, its body the message, once the run's phone has it. A message never resumes a paused run: answer the pause with :resume. A run holds at most 20 unread messages, and a message past that is refused, or one that arrives as the run finishes, which may then never be read or shown. To a run that ended (completed, failed or cancelled) while it still holds its phone (keep_alive_expires_at is in the future), the message starts the run's next turn on the same phone, and the run goes back to queued. Returns the run as it is. A code run, a queued or scheduled run, a run whose hold has ended, or a run whose phone cannot take messages yet is 409; an empty message, or one over 64 KiB, is 400. Minimum role required: Member # Start a run Source: https://docs.axilio.ai/api-reference/rest/runs/start-a-run /api-reference/openapi-backend.json post /runs Starts a run of a saved agent (agent_id, its current revision and settings) or an ad hoc run (task, with the model, output schema, limits, recording and telemetry given here). A phone_id target must be one of your organization's dedicated phones. While your organization is at its concurrency limit, a run that starts now is refused with 409. Booked with start_at, the run is scheduled instead. Minimum role required: Member # Start several runs Source: https://docs.axilio.ai/api-reference/rest/runs/start-several-runs /api-reference/openapi-backend.json post /runs:batchCreate Starts up to 1000 runs, each as POST /runs would. All are created or none is; an error names the request it is for. A batch may not name the same dedicated phone twice. The batch is refused with 409 while your organization is at its concurrency limit; its runs beyond the limit wait in the queue for a free slot. Minimum role required: Member # Stream a run's events Source: https://docs.axilio.ai/api-reference/rest/runs/stream-a-runs-events /api-reference/openapi-backend.json get /realtime/runs/{run_id}/events Streams an agent run as Server-Sent Events (text/event-stream): the frames of its session's telemetry that belong to the run, the model's text as it streams (model_delta logs), and the run's own events, live as the run runs. Each event's id is its resume cursor, its event name is the frame kind (span or log), and its data is the frame JSON. The run's events are logs: run_status (attribute axilio.run.status: running when the run starts on its phone or goes back to running), run_input_required (it paused for a person; the body is the question, with the input request's id, reason, requested_by, response_schema and expires_at as attributes), run_resumed (a person answered; the body is the action), run_result (how it ended on the phone: axilio.ok, axilio.run.output or axilio.run.error, and the summary as the body), and user_message (a person's message reached the run; the body is the message). The stream's last event is a run_status with the run's final status as the run itself has it (completed, failed or cancelled), so a run the caller cancelled ends cancelled. These events are not archived; the run itself has the result. A session started with telemetry off still streams its run's events and the model's text, and nothing else. A new reader (or one whose cursor is older than the live window) first receives the run's archived frames and then the live ones, deduplicated on span id and phase, followed by an event with no data that carries the cursor the replay ended at. Reconnect with the Last-Event-ID header to resume. The model's text is not archived: a reader that reconnects after it left the live window gets the step's answer from the run's steps instead. A comment line is sent every 15 seconds while the stream is idle. The stream closes after the run's final status; a reconnect after the end with nothing left gets 204. A run that has not reached a phone yet streams only comments until it does. Authenticate with the API key header, or open the run's stream_url, which carries a read-only token for the run's session. Org-scoped: another org's run reads as not found. Readers MUST ignore events with an unknown name, unknown fields, and unknown span_type or log_type values. Minimum role required: Viewer # Get phone session metrics Source: https://docs.axilio.ai/api-reference/rest/sessions/get-phone-session-metrics /api-reference/openapi-backend.json get /sessions/metrics Returns your organization's ended phone sessions over a window as points: one per hour or day in time_zone (and per group with group_by), each with the sessions, their phone time in seconds and what it cost. A session counts in the bucket it was billed in, as it ended, dedicated phones' at no cost; sessions still going are not counted. The window defaults to the last 7 days and may start at most a year back; hour buckets need a window of 7 days or less. Minimum role required: Viewer # Agent skill markdown Source: https://docs.axilio.ai/api-reference/rest/skill/agent-skill-markdown /api-reference/openapi-backend.json get /skill The canonical Axilio agent skill: markdown instructions that teach a coding agent to drive a phone through the axilio CLI, then hand back the equivalent SDK script. Single source of truth — the dashboard's "Get agent prompt" action and `axilio init` both fetch this instead of carrying their own copy, so a change here reaches both without a separate deploy. # Get usage metrics Source: https://docs.axilio.ai/api-reference/rest/usage/get-usage-metrics /api-reference/openapi-backend.json get /usage/metrics Returns your organization's usage spend over a window as points: one per hour or day in time_zone (and per group with group_by: product, agent, model, api_key, schedule or device_class), each with the total, phone-session and inference spend in microdollars, the phone seconds and tokens it bought, zero when nothing was billed. The window defaults to the last 7 days and may start at most a year back; hour buckets need a window of 7 days or less. Minimum role required: Viewer # List inference calls Source: https://docs.axilio.ai/api-reference/rest/usage/list-inference-calls /api-reference/openapi-backend.json get /usage/inferences Paginated, filterable list of inference calls (detect, locate, element_locate, and agent_chat for an agent run's model steps) the caller's user was billed for over a required time window (created_after/created_before). Filters: endpoint, model, session, free-text search. Order with order_by ('<field> <asc|desc>'). Minimum role required: Viewer # List phone sessions with cost Source: https://docs.axilio.ai/api-reference/rest/usage/list-phone-sessions-with-cost /api-reference/openapi-backend.json get /usage/sessions Paginated, filterable list of the caller's phone sessions over a required time window (allocated_after/allocated_before), each row carrying billing detail: phone-time cost, inference cost, and combined total in microdollars, billing processing status, and allocation source. Filters: session status, billing processing status, agent, allocation source, free-text search. Order with order_by ('<field> <asc|desc>'). Minimum role required: Viewer # Delete a webhook endpoint Source: https://docs.axilio.ai/api-reference/rest/webhooks/delete-a-webhook-endpoint /api-reference/openapi-backend.json delete /webhooks/endpoints/{endpoint_id} Removes a webhook endpoint. Subsequent events are no longer delivered to it. A missing or wrong-organization id returns 404. Minimum role required: Member # Get a delivery-log event Source: https://docs.axilio.ai/api-reference/rest/webhooks/get-a-delivery-log-event /api-reference/openapi-backend.json get /webhooks/deliveries/{event_id} Fetches one event from the delivery log with the payload that was delivered and the endpoints it was routed to. A missing or wrong-organization id returns 404. Minimum role required: Viewer # Get a webhook endpoint Source: https://docs.axilio.ai/api-reference/rest/webhooks/get-a-webhook-endpoint /api-reference/openapi-backend.json get /webhooks/endpoints/{endpoint_id} Fetches one webhook endpoint by id. A missing or wrong-organization id returns 404. Minimum role required: Viewer # Get delivery metrics Source: https://docs.axilio.ai/api-reference/rest/webhooks/get-delivery-metrics /api-reference/openapi-backend.json get /webhooks/metrics Returns delivery volume, error rate and destination response time over a window as points: one per hour or day in time_zone, for every destination together, one (endpoint_id), or each one (group_by=endpoint_id). Counts are zero and latencies null for a bucket with no attempts. The window defaults to the last 7 days and may start at most a year back; hour buckets need a window of 7 days or less. Minimum role required: Viewer # List delivery attempts for an event Source: https://docs.axilio.ai/api-reference/rest/webhooks/list-delivery-attempts-for-an-event /api-reference/openapi-backend.json get /webhooks/deliveries/{event_id}/attempts Lists the delivery attempts for one event across the organization's endpoints, newest first, including automatic retries and manual replays. Pages the same way as the delivery log. Minimum role required: Viewer # List delivery attempts to an endpoint Source: https://docs.axilio.ai/api-reference/rest/webhooks/list-delivery-attempts-to-an-endpoint /api-reference/openapi-backend.json get /webhooks/endpoints/{endpoint_id}/attempts Lists every delivery attempt made to one endpoint across the organization's events, newest first, each with its outcome, the HTTP status the endpoint returned, its try number and the event it delivered. Filter by outcome (status) or event type. Pages the same way as the delivery log. A missing or wrong-organization endpoint id returns 404. Minimum role required: Viewer # List subscribable event types Source: https://docs.axilio.ai/api-reference/rest/webhooks/list-subscribable-event-types /api-reference/openapi-backend.json get /webhooks/event-types Lists the event types an endpoint can subscribe to, with a description of each, so a caller knows what to subscribe to before registering an endpoint. Minimum role required: Viewer # List the delivery log Source: https://docs.axilio.ai/api-reference/rest/webhooks/list-the-delivery-log /api-reference/openapi-backend.json get /webhooks/deliveries Lists events published for the caller's organization, newest first, one page at a time. Narrow it to one event type (event_type), one destination (endpoint_id) or one message's events (message_id). Pass the response's next_cursor as cursor to fetch the following page. Minimum role required: Viewer # List webhook endpoints Source: https://docs.axilio.ai/api-reference/rest/webhooks/list-webhook-endpoints /api-reference/openapi-backend.json get /webhooks/endpoints Lists every webhook endpoint registered for the caller's organization. The collection is bounded, so it is returned whole rather than paged. Signing secrets are never included. Minimum role required: Viewer # Register a webhook endpoint Source: https://docs.axilio.ai/api-reference/rest/webhooks/register-a-webhook-endpoint /api-reference/openapi-backend.json post /webhooks/endpoints Registers an HTTPS endpoint to receive signed events for the caller's organization, subscribed to a set of event types (or all). The signing secret is returned exactly once. Minimum role required: Member # Replay a delivery Source: https://docs.axilio.ai/api-reference/rest/webhooks/replay-a-delivery /api-reference/openapi-backend.json post /webhooks/deliveries/{event_id}:replay Re-drives one event to one endpoint. Used to recover a dead-lettered delivery once the endpoint is healthy again. The replay is queued, not delivered synchronously; the response identifies it, and its attempt appears under the event's attempts with manual set. Minimum role required: Member # Rotate a webhook endpoint's signing secret Source: https://docs.axilio.ai/api-reference/rest/webhooks/rotate-a-webhook-endpoints-signing-secret /api-reference/openapi-backend.json post /webhooks/endpoints/{endpoint_id}:rotateSecret Issues a new signing secret for the endpoint, returned exactly once. With mode graceful (the default) the previous secret keeps verifying for 24 hours (previous_secret_invalid_at in the response) so the new secret can be rolled out across a deploy without dropping deliveries in flight. With mode immediate the previous secret stops verifying at once, for a secret that may be compromised. A missing or wrong-organization id returns 404. Minimum role required: Member # Send a test event Source: https://docs.axilio.ai/api-reference/rest/webhooks/send-a-test-event /api-reference/openapi-backend.json post /webhooks/deliveries:sendTest Publishes a simulated event to the caller's organization so an endpoint can be verified end to end. Name an event_type from the catalog to simulate it with that type's sample payload (default webhook.test), and an endpoint_id to deliver to that endpoint only (a missing or wrong-organization id returns 404; an endpoint not subscribed to the type is a validation error rather than a silent no-op). Omit the body to deliver webhook.test to every endpoint subscribed to it. The event is queued, not delivered synchronously; the response carries the event id to look up in the delivery log. Minimum role required: Member # Detect Screenshot Source: https://docs.axilio.ai/api-reference/screenshots/detect-screenshot /api-reference/openapi-argus.json post /api/v1/screenshots:detect Detect UI elements and read text in a screenshot. # Locate In Screenshot Source: https://docs.axilio.ai/api-reference/screenshots/locate-in-screenshot /api-reference/openapi-argus.json post /api/v1/screenshots:locate Find the element a query describes in a screenshot: a point, or an OCR text. # Versioning & Deprecation Source: https://docs.axilio.ai/api-reference/versioning How the API is versioned, what can change without notice, how deprecations are announced, and the rate limits that apply to every caller. This page covers what stays stable, what can change, and how you'll hear about anything that changes or goes away. ## Versioning The REST API is versioned in the URL path. Everything on these pages lives under: ``` https://api.axilio.ai/api/v1 ``` `/api/v1` is a stability boundary. Within it: * **Non-breaking changes ship without notice.** New endpoints, new optional request fields, new response fields, new enum values, and new headers can appear at any time. Write your client so extra fields it doesn't recognize are ignored, not errors. * **Check the dated changes page for breaking changes.** When a release renames or removes a field, parameter, path or webhook field in `/api/v1`, we publish a changes page that lists every old name with its replacement, for example [October 2026](/api-reference/api-changes-2026-10). Upgrade your SDK and CLI with that release. The [OpenAPI spec](https://api.axilio.ai/openapi.json) is regenerated with every release and carries the current API version in `info.version`. If you generate a client, generate it from there. ## Deprecation When we deprecate an endpoint or field ahead of removing it, rather than changing it in a release, you hear about it three ways, well before anything stops working: 1. **In the spec and on these pages.** The OpenAPI spec marks the operation deprecated (`deprecated: true`), and its reference page carries a deprecated badge with a pointer to the replacement. 2. **On the wire.** Responses from a deprecated endpoint carry a `Deprecation` header, and once a removal date is set, a `Sunset` header with that date (RFC 8594). Log these in your client and you will never be surprised. 3. **With a real migration window.** A deprecated endpoint keeps working for at least **90 days** after the `Sunset` header first appears, and we contact organizations that are still calling it before it goes away. We never remove anything quietly: we either deprecate it first or list it on a changes page. ## Rate limits Every caller gets a per-minute request budget, keyed by API key (or by IP for unauthenticated requests). Each response tells you where you stand: | Header | Meaning | | - | - | | `RateLimit-Limit` | Your budget for the current window (requests per minute). | | `RateLimit-Remaining` | Requests left in the window. | | `RateLimit-Reset` | Seconds until the window resets. | Past the budget, requests return `429 Too Many Requests` with a `Retry-After` header (seconds). Back off for that long and retry; the response body is the standard [error shape](/reference/errors). The default budget is generous for normal use, including fleet-scale work: one `runs:batchCreate` call starts up to 1,000 runs across many phones, so driving thousands of phones does not mean thousands of API calls. If you're hitting the limit with a legitimate workload, [contact us](https://axilio.ai/contact). ## What to do as a client * Ignore unknown response fields and headers instead of failing on them. * Watch for `Deprecation` and `Sunset` headers in responses and surface them in your logs. * Honor `Retry-After` on `429`s rather than retrying immediately. * Regenerate clients from the [live spec](https://api.axilio.ai/openapi.json), not from a saved copy. # Authentication Source: https://docs.axilio.ai/authentication Create an organization-scoped API key or sign in to the CLI, then authenticate Python, Go, and REST requests safely. Axilio authenticates SDK and REST requests with an **API key**. The CLI can use an API key or a browser sign-in. ## Create an API key Open [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys) for your organization. The organization-specific page is `/dashboard//api-keys`. Choose **Create API Key**, give the key a name, and copy the secret. Keys start with `axl_`. > [!WARNING] > > Axilio shows the secret only when you create the key. Store it in a secret > manager. Never commit it, paste it into logs, or pass it as a command-line > argument that your shell may record. ## Authenticate an interface > [tab] CLI > > For interactive use, sign in through your browser and verify the session: > > ```bash theme={null} > axilio login > axilio status > ``` > > For non-interactive use, expose an API key through the environment: > > ```bash macOS and Linux theme={null} > export AXILIO_API_KEY=axl_your_key_here > axilio status > ``` > > ```powershell Windows (PowerShell) theme={null} > $env:AXILIO_API_KEY = "axl_your_key_here" > axilio status > ``` > > A browser session can switch organizations with `axilio orgs use `. An API key remains scoped to the organization that > created it. > [tab] Python SDK > > Set the environment variable before starting your process: > > ```bash macOS and Linux theme={null} > export AXILIO_API_KEY=axl_your_key_here > ``` > > ```powershell Windows (PowerShell) theme={null} > $env:AXILIO_API_KEY = "axl_your_key_here" > ``` > > `Client` reads it automatically: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > ``` > > You can also pass `api_key=` from a secret provider: > > ```python theme={null} > client = Client(api_key=secret_value) > ``` > [tab] Go SDK > > Read the secret from your environment or secret provider and pass it to > the generated client: > > ```go theme={null} > import ( > "os" > > client "github.com/axilioai/platform-go/client" > option "github.com/axilioai/platform-go/option" > ) > > c := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > ``` > [tab] REST API > > REST API References: > > * [**List the organization's files**](/api-reference/rest/files/list-the-orgs-files): `GET /files` > > Send the key in the `X-Axilio-Api-Key` header: > > ```bash macOS and Linux theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/files \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > ```powershell Windows (PowerShell) theme={null} > curl.exe --silent --show-error --fail-with-body ` > --url https://api.axilio.ai/api/v1/files ` > --header "X-Axilio-Api-Key: $env:AXILIO_API_KEY" > ``` > > The other REST examples in these docs use Bash syntax. See > [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > Do not send the key to presigned file upload or download URLs. Those URLs > authorize the storage request themselves and expire after a short time. ## Python client options | Setting | Default | What it does | | - | - | - | | `api_key` | `AXILIO_API_KEY` | Authenticates requests. Required. | | `base_url` | `AXILIO_BASE_URL`, then `https://api.axilio.ai` | Selects the API host. | | `timeout` | `30.0` | Sets the per-request timeout in seconds. | | `max_retries` | `3` | Retries temporary failures before returning an error. | > [!NOTE] > > Each API key belongs to one organization. Create a separate key for each > organization that your application needs to access. ## Next steps > [card] [SDK quickstart](/quickstart) > > Allocate and inspect your first phone with Python. > [card] [CLI](/cli) > > Sign in and drive a phone from your terminal. # Balance, credits, and invoices Source: https://docs.axilio.ai/billing/balance-and-invoices Understand credit priority and expiration, add funds, configure alerts, and download invoice PDFs. ## Check the balance > [tab] Dashboard > > Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing) after choosing your > organization. The balance card separates included and purchased credit. > [tab] CLI > > ```bash theme={null} > axilio billing balance > axilio billing balance -o json > ``` > [tab] Python SDK > > ```python theme={null} > balance = client.billing.get_balance() > > print(balance.balance_display) > print(balance.included_microdollars) > print(balance.purchased_microdollars) > print(balance.purchased_expires_at) > ``` > [tab] Go SDK > > ```go theme={null} > balance, err := api.Billing.GetBalance(ctx) > if err != nil { > log.Fatal(err) > } > fmt.Println(balance.BalanceDisplay) > ``` > [tab] REST API > > REST API References: > > * [**Get account balance**](/api-reference/rest/billing/get-account-balance): `GET /billing/balance` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/balance \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## How credits work Axilio applies credits and charges using these rules: 1. **Included credit is spent first.** Axilio spends your current period's free allowance before using any purchased credit. This ensures any purchases of additional credit are never wasted. 2. **Included credit resets instead of accumulating.** At renewal, any unused included credit lapses. The new period begins with exactly the new allowance. 3. **Purchased credits are only spent after any included credits are spent.** Top-ups and automatic recharges create separate purchased lots. When more than one lot remains, Axilio spends the lot with the soonest expiration first (FIFO) to minimize expired credits. 4. **Each purchased lot expires one year after settlement.** The one-year clock begins when Stripe settles that purchase. Unused credit in that lot is removed at expiration. 5. **Automatic recharge restores your balance to the amount you configure.** It is not a separate credit bucket. When your total balance falls below the configured **Threshold**, Axilio purchases enough credit to reach the configured **Recharge to** amount. That purchase then follows the same debt-first and expiration rules. 6. **New credit repays debt first.** If your balance is negative, a grant or purchase first reduces that deficit. Only the remainder becomes spendable credit. For example, a `$10` top-up against a `-$1` balance leaves `$9` available. The balance shown in the dashboard is the combined amount available after these rules are applied. The `GET /billing/balance` response exposes the same result as separate fields: * `balance_microdollars` is the combined balance. It can be negative when charges exceed the available credit. * `included_microdollars` is the unused included credit in the current period. * `purchased_microdollars` is the total remaining purchased credit across all unexpired lots. * `purchased_expires_at` is when the purchased lot that expires next stops being usable. It is absent when no purchased credit remains. When `balance_microdollars` is negative, both live-credit fields are zero. The next grant or purchase repays that deficit before adding spendable credit. Dedicated-phone rental charges are separate from these credit buckets. A dedicated session does not consume phone-time credit, but metered vision calls during that session can consume the balance. See [Dedicated phone pricing](/billing/plans) for rental periods and [Models and pricing](/drivers/models) for metered inference pricing. Axilio does not provide a routine self-service refund path. Contact support if you believe a purchase or charge requires review. > [accordion] See how credit moves through each bucket > > ```mermaid theme={null} > %%{init: {'flowchart': {'defaultRenderer': 'elk', 'curve': 'basis'}}}%% > flowchart TB > renewal["Account renewal
when included credit applies"] > topup["Top-up or automatic recharge
after Stripe settles"] > deficit{"Existing negative balance?"} > repay["Repay the deficit first"] > > included[("Included credit
maximum = current allowance")] > purchased[("Purchased credit lots
each has its own expiry")] > > charge["Metered usage charge"] > negative["Negative balance
after all live credit is spent"] > lapse["Unused included credit lapses"] > expiry["Unused purchased lot expires"] > > renewal -->|"reset to the new allowance"| included > included -.->|"at the next renewal"| lapse > > topup --> deficit > deficit -->|"yes"| repay > repay -->|"credit left after repayment"| purchased > deficit -->|"no"| purchased > > included -->|"1 · spend first"| charge > purchased -->|"2 · soonest expiry first"| charge > charge -->|"3 · no credit remains"| negative > negative -.->|"the next credit returns here"| deficit > purchased -.->|"one year after settlement"| expiry > > classDef source fill:#052e24,stroke:#10b981,color:#ecfdf5,stroke-width:2px; > classDef bucket fill:#0f172a,stroke:#34d399,color:#f8fafc,stroke-width:2px; > classDef terminal fill:#2a1111,stroke:#f87171,color:#fff1f2,stroke-width:2px; > classDef exit fill:#262626,stroke:#737373,color:#f5f5f5; > class renewal,topup,repay source; > class included,purchased bucket; > class negative terminal; > class lapse,expiry exit; > ``` ## Add funds Adding funds and managing payment methods are dashboard-only actions: 1. Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing). 2. Click **Add Funds**. 3. Choose an amount and complete checkout. The CLI, SDKs, and public REST API do not create an add-funds checkout or customer-portal session. ## List and download invoices > [tab] Dashboard > > Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), select > **Invoices**, filter the list, then click **PDF** on the invoice you need. > [tab] Python SDK > > ```python theme={null} > history = client.billing.get_history(limit=20, offset=0) > > for invoice in history.items or []: > print(invoice.id, invoice.invoice_number, invoice.status) > > pdf = client.billing.download_invoice("inv_123") > print(pdf.download_url, pdf.expires_at) > ``` > [tab] Go SDK > > ```go theme={null} > history, err := api.Billing.GetHistory(ctx, &platformgo.BillingGetHistoryRequest{ > Limit: platformgo.Int64(20), > Offset: platformgo.Int64(0), > }) > > pdf, err := api.Billing.DownloadInvoice(ctx, &platformgo.BillingDownloadInvoiceRequest{ > InvoiceID: "inv_123", > }) > fmt.Println(pdf.DownloadURL, pdf.ExpiresAt) > ``` > [tab] REST API > > REST API References: > > * [**List billing history**](/api-reference/rest/billing/list-billing-history): `GET /billing/history` > * [**Get an invoice PDF URL**](/api-reference/rest/billing/get-an-invoice-pdf-url): `GET /billing/history/{invoice_id}/pdf` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/billing/history?limit=20&offset=0" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/history/inv_123/pdf \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` The CLI does not currently list or download invoices. A PDF URL is temporary; request a fresh one instead of storing it. ## Configure automatic recharge Automatic recharge charges the saved payment method when the balance falls below a threshold, restoring it to a target amount. Adding the payment method itself remains a dashboard action. > [tab] Dashboard > > Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), select > **Preferences**, then find **Auto-recharge**. If you are an organization > admin, you can: > > 1. Add or update the saved card through **Manage payment method**. > 2. Set **Threshold** to the balance that triggers a recharge. > 3. Set **Recharge to** to the balance Axilio should restore. > 4. Turn on **Automatically top up**. Enabling it saves the amounts shown. > > If auto-recharge is already on, select **Save** after changing either > amount without toggling it off. > > The recharge target must be at least \$5 and greater than the threshold. > If you are not an administrator, you can view the current state but cannot > change it. > [tab] Python SDK > > ```python theme={null} > settings = client.billing.get_auto_recharge() > > updated = client.billing.update_auto_recharge( > enabled=True, > threshold_microdollars=5_000_000, > target_microdollars=25_000_000, > ) > ``` > [tab] Go SDK > > ```go theme={null} > settings, err := api.Billing.GetAutoRecharge(ctx) > > updated, err := api.Billing.UpdateAutoRecharge( > ctx, > &platformgo.SubscriptionAutoRechargeSettingsUpdate{ > Enabled: platformgo.Bool(true), > ThresholdMicrodollars: platformgo.Int64(5_000_000), > TargetMicrodollars: platformgo.Int64(25_000_000), > }, > ) > ``` > [tab] REST API > > REST API References: > > * [**Get auto-recharge settings**](/api-reference/rest/billing/get-auto-recharge-settings): `GET /billing/auto-recharge` > * [**Update auto-recharge settings**](/api-reference/rest/billing/update-auto-recharge-settings): `PATCH /billing/auto-recharge` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/auto-recharge \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --request PATCH \ > --url https://api.axilio.ai/api/v1/billing/auto-recharge \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "enabled": true, > "threshold_microdollars": 5000000, > "target_microdollars": 25000000 > }' > ``` The CLI does not configure automatic recharge; its billing commands are read-only. ## Configure low-balance alerts Low-balance alerts warn you before insufficient funds can block a session. Negative-balance alerts are separate, always enabled, and cannot be turned off. > [tab] Dashboard > > Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), select > **Preferences**, then find **Usage alerts**. If you are an organization > admin, you can: > > 1. Set **Alert threshold** to a positive dollar amount. > 2. Turn on **Low-balance alert**. Enabling it saves the threshold shown. > > If the alert is already on, select **Save** after changing the threshold. > If you are not an administrator, you can view its current state but cannot > change it. > [tab] CLI > > The CLI does not currently read or configure low-balance alerts. Its > relevant command is `axilio billing balance`. > [tab] Python SDK > > ```python theme={null} > alerts = client.billing.get_usage_alerts() > print(alerts.enabled, alerts.threshold_microdollars) > > updated = client.billing.update_usage_alerts( > enabled=True, > threshold_microdollars=10_000_000, > ) > ``` > [tab] Go SDK > > ```go theme={null} > alerts, err := api.Billing.GetUsageAlerts(ctx) > if err != nil { > log.Fatal(err) > } > fmt.Println(alerts.Enabled, alerts.ThresholdMicrodollars) > > updated, err := api.Billing.UpdateUsageAlerts( > ctx, > &platformgo.SubscriptionUsageAlertSettingsUpdate{ > Enabled: platformgo.Bool(true), > ThresholdMicrodollars: platformgo.Int64(10_000_000), > }, > ) > if err != nil { > log.Fatal(err) > } > fmt.Println(updated.Enabled, updated.ThresholdMicrodollars) > ``` > [tab] REST API > > REST API References: > > * [**Get usage-alert settings**](/api-reference/rest/billing/get-usage-alert-settings): `GET /billing/usage-alerts` > * [**Update usage-alert settings**](/api-reference/rest/billing/update-usage-alert-settings): `PATCH /billing/usage-alerts` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/usage-alerts \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --request PATCH \ > --url https://api.axilio.ai/api/v1/billing/usage-alerts \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "enabled": true, > "threshold_microdollars": 10000000 > }' > ``` Both updates are partial: send only the fields you are changing, and the others keep their values. Amounts are in microdollars (1,000,000 = \$1.00). Updating auto-recharge or alerts requires an organization admin. An API key inherits its creator's organization role. # Billing Source: https://docs.axilio.ai/billing/overview Understand organization plans, shared-pool phone time, dedicated rentals, inference usage, balance, and invoices. Billing and usage are related but separate: * [**Usage**](/usage/overview) shows phone sessions, aggregate spend, and individual inference charges. * [**Balance, credits, and invoices**](/billing/balance-and-invoices) explains available credit, purchases, expiration, and payment records. * **Current plan and plan changes** appear on the [**Billing**](https://app.axilio.ai/dashboard/axilio/billing) page. The current organization plan appears in the summary near the top. Click **Change Plan** to open the plan-selection modal. Its muted gray style does not mean it is disabled. There is no separate **Plans** page or tab in the dashboard. * [**Dedicated phone pricing**](/billing/plans) shows the current daily, weekly, and monthly rental prices. ## Organization plans Every organization is on one plan. Your plan sets the subscription price, the usage credit it includes, the rate for shared-pool phone time, how many runs can execute at once, and how long Axilio keeps logs and Telemetry: Each paid invoice adds your plan's included credit to your balance, so a yearly subscription gives you twelve months of credit at once. On Hobby, you get the credit once, when you sign up. Your Enterprise contract sets its own terms, including retention. The current plan appears on the dashboard's **Billing** page. The **Change Plan** modal shows the options available to your organization without navigating away from **Billing**. The page's tabs are **Dedicated Phones**, **Invoices**, and **Preferences**. ## Shared-pool and dedicated phones A session runs on one of two kinds of phone: * **Shared pool**: phones from Axilio's fleet, available to every organization. You pay for session time per second, at your plan's rate (see the table above). When the session ends, Axilio deducts the charge from your balance at the rate of the plan the session started on. * **Dedicated**: a phone your organization rents for a daily, weekly, or monthly period. You pay for the rental, and its sessions add no phone-time charge. The rental is separate from your organization plan. See [Dedicated phone pricing](/billing/plans). If you allocate without a `phone_id` or a `pool`, you get a shared-pool phone. To use a rented phone, pass `pool: "dedicated"` or the phone's `phone_id`. You need a balance above zero to allocate a shared-pool phone. See [Errors](/reference/errors) for what a low balance returns. ## What is billed | Product | Billing model | | - | - | | Shared-pool phone | Per second of session time, at your plan's rate | | Dedicated phone | Flat rental; its sessions add no phone-time charge | | Vision inference | Metered per call according to the selected model | Your balance can contain included credit and purchased credit. See [How credits work](/billing/balance-and-invoices#how-credits-work) for spending priority, renewal behavior, and expiration. ## Where actions are available | Action | Dashboard | CLI | Python / Go / REST | | - | - | - | - | | Read balance | Yes | Yes | Yes | | Read usage | Yes | Metrics and inferences | Yes | | List invoices and download PDFs | Yes | No | Yes | | Configure auto-recharge and balance alerts | Yes | No | Yes | | Add funds or add a payment method | Yes | No | No | | Rent a phone or cancel its renewal | Yes | No | Read-only rental APIs | ## Next steps > [card] [Usage](/usage/overview) > > Review sessions, aggregate spend, and inference rows. > [card] [Balance, credits, and invoices](/billing/balance-and-invoices) > > Understand credit priority and expiration, add funds, and download invoices. > [card] [Dedicated phone pricing](/billing/plans) > > Compare daily, weekly, and monthly rentals. # Dedicated phone pricing Source: https://docs.axilio.ai/billing/plans Compare daily, weekly, and monthly pricing for dedicated Axilio phones. Dedicated phones are real Android devices reserved exclusively for your organization. Pricing is per phone for the rental period you choose. Every dedicated-phone rental includes: * Exclusive access to one phone for your organization. * Unlimited phone hours during the rental period. * Persistent apps and device state between sessions. * No additional phone-time charge for sessions on that phone. > [!NOTE] > > Vision inference and other metered services are billed separately. See > [Models and pricing](/drivers/models) for current inference pricing. ## Rent a phone Rentals begin in the dashboard: 1. Open [**Phones**](https://app.axilio.ai/dashboard/axilio/phones). 2. Click **Rent A Phone**. 3. Choose **Daily**, **Weekly**, or **Monthly**. 4. Complete checkout to reserve the phone for your organization. ## Inspect or cancel renewals > [tab] Dashboard > > Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), then > select **Dedicated Phones**. You can inspect the rental period, next > renewal, payment status, and monthly-equivalent cost. You can also cancel > an automatic renewal from this view. > [tab] Python SDK > > ```python theme={null} > rentals = client.billing.get_rental_subscriptions() > > for rental in rentals.subscriptions or []: > print( > rental.phone_id, > rental.plan_name, > rental.status, > rental.current_period_end_at, > ) > ``` > [tab] Go SDK > > ```go theme={null} > rentals, err := api.Billing.GetRentalSubscriptions(ctx) > if err != nil { > log.Fatal(err) > } > for _, rental := range rentals.Subscriptions { > phoneID := "not assigned" > if rental.PhoneID != nil { > phoneID = *rental.PhoneID > } > fmt.Println( > phoneID, > rental.PlanName, > rental.Status, > rental.CurrentPeriodEndAt, > ) > } > ``` > [tab] REST API > > REST API References: > > * [**List phone rentals**](/api-reference/rest/billing/list-phone-rentals): `GET /billing/phone-rental-subscriptions` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/phone-rental-subscriptions \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` The SDKs and REST API can inspect rentals. Starting checkout and canceling a renewal remain dashboard actions. ## Use a dedicated phone See [Dedicated phones](/devices/dedicated) to learn how rentals affect phone availability, allocation, session charges, renewal, and device state. # Explore with the CLI Source: https://docs.axilio.ai/cli Install the Axilio CLI, explore a mobile task one action at a time, and use platform operations from a terminal. The `axilio` CLI exposes phone control and platform operations to people, shell scripts, and coding agents. Human-readable tables are the default. Most commands also provide structured JSON output. ## Install > [tab] Homebrew > > On macOS, install the CLI with Homebrew: > > ```bash theme={null} > brew install axilioai/tap/axilio > ``` > > Homebrew also installs the manual. Read it with `man axilio`. > [tab] Install script > > On macOS or Linux, install the latest release and verify its published > checksum with the install script: > > ```bash theme={null} > curl -fsSL https://axilio.ai/install.sh | sh > ``` > > The script chooses the binary for your operating system and architecture. > Set `INSTALL_DIR` if you need a non-default destination. > [tab] Windows (PowerShell) > > On Windows, install the latest release from PowerShell. The script > verifies the published checksum, installs `axilio.exe` to > `%LOCALAPPDATA%\Programs\axilio\bin`, and adds that folder to your user > `PATH`: > > ```powershell theme={null} > irm https://axilio.ai/install.ps1 | iex > ``` > > Open a new terminal afterwards. The script works in Windows PowerShell 5.1 > and PowerShell 7 and needs no administrator rights. Set > `$env:INSTALL_DIR` first if you need a non-default destination. Windows > has no `man`, so read the installed manual with `axilio help --html`. > [tab] Release archive > > Prebuilt archives for Linux, macOS, and Windows are available on the > [GitHub releases page](https://github.com/axilioai/cli/releases). On > Windows, extract the zip and add the folder that contains `axilio.exe` to > your `PATH`. ## Sign in Use browser authentication for an interactive terminal: ```bash theme={null} axilio login axilio status ``` For CI or another non-interactive environment, supply an organization-scoped API key through `AXILIO_API_KEY`. See [Authentication](/authentication). ## Inspect a phone from the terminal List the dedicated phones available to allocate, start a session, inspect the screen, and save a screenshot: ```bash macOS and Linux theme={null} axilio phones list eval "$(axilio sessions start --phone-id ph_123 --export)" axilio phone observe axilio phone screenshot --out screen.png axilio sessions stop "$AXILIO_SESSION" --yes && unset AXILIO_SESSION ``` ```powershell Windows (PowerShell) theme={null} axilio phones list axilio sessions start --phone-id ph_123 --export | Invoke-Expression axilio phone observe axilio phone screenshot --out screen.png axilio sessions stop $env:AXILIO_SESSION --yes if ($LASTEXITCODE -eq 0) { Remove-Item Env:AXILIO_SESSION } ``` Replace `ph_123` with an ID returned by `axilio phones list`. The exported `AXILIO_SESSION` selects the session for subsequent `axilio phone` commands. Unlike an SDK context manager, the CLI session remains active until you stop it, so always release the phone when you finish. ## Explore a read-only task Work out a mobile task one action at a time before turning it into an SDK script. This example opens X, searches for `agentic infrastructure`, and scrolls through the first few **Top** results without changing the account. ### Before you start * Install the CLI above, run `axilio login`, and verify the setup with `axilio doctor`. * Use a dedicated phone where X is already installed and signed in. * Make sure you are authorized to inspect the signed-in account. This guide does not post, follow, like, message, or change settings. > [!TIP] > > Explore the available commands with `axilio --help`. Run `axilio help --html` > for the complete installed reference, or `man axilio` on macOS and Linux. ### Start a session List dedicated phones that can be allocated now, then start a session on one and select it for the current shell: ```bash macOS and Linux theme={null} axilio phones list eval "$(axilio sessions start --phone-id ph_123 --export)" cleanup() { if [ -n "${AXILIO_SESSION:-}" ]; then axilio sessions stop "$AXILIO_SESSION" --yes && unset AXILIO_SESSION fi } trap cleanup EXIT ``` ```powershell Windows (PowerShell) theme={null} axilio phones list axilio sessions start --phone-id ph_123 --export | Invoke-Expression function cleanup { if ($env:AXILIO_SESSION) { axilio sessions stop $env:AXILIO_SESSION --yes if ($LASTEXITCODE -eq 0) { Remove-Item Env:AXILIO_SESSION } } } ``` Replace `ph_123` with an ID from the first command. Every following `axilio phone` command uses `AXILIO_SESSION` automatically. In Bash or Zsh, the cleanup trap releases the phone if a later command fails or you exit the shell. PowerShell has no exit trap for an interactive session, so run `cleanup` yourself when you finish. `cleanup` clears `AXILIO_SESSION` only after the stop succeeds, so if a stop fails you can run it again. > [!NOTE] > > The pre-existing demo below uses `axilio phones mine`, which remains a valid > way to inspect all dedicated inventory. This guide uses `axilio phones list` > because it filters the result to phones that can be allocated immediately. The CLI saves session connection information locally. When several local sessions exist, use `--session` or `AXILIO_SESSION` to select the one a phone command should control. ### Open X Observe the launcher, open the app drawer, and tap X: ```bash theme={null} axilio phone observe axilio phone swipe 244 950 244 300 axilio phone tap --query "the X app" axilio phone observe ``` The swipe uses coordinates because opening the whole app drawer is a screen gesture, not a tappable element. These coordinates match the 488x1080 frame in the demo. Adjust them if `observe` reports a different screen size. The X tap uses a semantic target because one is available. ### Search X opens on the home timeline, where search is an icon. Open the search screen, focus the field, enter the query, and submit it: ```bash theme={null} axilio phone tap --query "the search icon" axilio phone tap --query "the Search field" axilio phone type "agentic infrastructure" axilio phone key enter axilio phone observe ``` The final `observe` verifies that X opened the **Top** results for the intended query before you continue. ### Scroll through the top results Run these commands up to three times. Observe after each swipe so you can check the screen before continuing: ```bash theme={null} axilio phone swipe 244 900 244 260 axilio phone observe ``` This swipe also uses coordinates because it scrolls the whole results view. ### Stop the session Stop as soon as you finish. Keep the session ID for the recording, because `cleanup` clears `AXILIO_SESSION`: ```bash macOS and Linux theme={null} SESSION_ID=$AXILIO_SESSION cleanup trap - EXIT ``` ```powershell Windows (PowerShell) theme={null} $sessionId = $env:AXILIO_SESSION cleanup ``` Screen recording is off by default. When a session or agent turns it on, download the recording from the CLI once processing finishes: ```bash macOS and Linux theme={null} axilio sessions recording "$SESSION_ID" --wait --out axilio-session.mp4 ``` ```powershell Windows (PowerShell) theme={null} axilio sessions recording $sessionId --wait --out axilio-session.mp4 ``` `--wait` polls while the recording is still processing. Without `--out` the command prints the status and a short-lived URL instead. Or open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions), select the completed session, and use its recording when the status becomes `ready`. Python, Go, and REST lookup options are documented in [Recordings](/observability/recordings). ### Watch the demo The original poster and recording still match the X interaction above. The inventory command shown in the recording is the supported `phones mine` variant described earlier. ### Turn the exploration into code Once the sequence is reliable, reproduce it with the [Python or Go SDK](/quickstart). Keep semantic targets for taps, retain coordinate gestures only where the whole screen must move, observe after important transitions, and preserve guaranteed session cleanup. ## Run agents An [agent](/agents/overview) is a saved task for a phone, as instructions for a model or as Python code. These commands need CLI 0.14.0 or later. Create an agent, run it, and follow the run to its end: ```bash theme={null} axilio agents create android-version \ --instructions "Open Settings, go to About phone, and report the Android version." axilio agents list --search android axilio runs start --agent --watch axilio runs start --task "Open Settings and report the Android version" --watch ``` Author a code agent and keep its revisions: ```bash theme={null} axilio agents create search-flow --code search.py axilio agents pull --out search.py axilio agents push search.py -m "handle sign-in" axilio agents revisions axilio agents rollback ``` Inspect and measure runs: ```bash theme={null} axilio runs list --agent --status failed axilio runs list --mode agent --schedule axilio runs get axilio runs metrics --agent axilio runs cancel --yes ``` `runs start` runs the agent's current revision, or with `--task`, a one-off instruction with nothing saved. Pass `--agent` or `--task`, not both. The run takes one of your dedicated phones, whichever is free, and `--phone-id` pins a specific one. `--variables` passes a JSON object of inputs. `--watch` follows the run like `runs watch`, which ends with the run even while its phone is still held, and prints a paused run's question for you to answer in the Dashboard. `runs get` shows the run's result and output. `runs metrics` covers the last 7 days unless you pass `--from` and `--to`. ### Upgrade from an earlier CLI CLI 0.14.0 follows the Agents release of the API. It has four breaking changes: * `workflows` is now `agents`, and `workflows restore` is now `agents rollback`. `runs start` takes `--agent ` instead of a workflow ID, and `--count` is gone. `sessions start --workflow` is gone, and `sessions list --history --workflow` is now `--agent`. * `runs history` and `runs stats` are removed. `runs list` filters by `--agent`, `--schedule`, `--mode` and `--status`, and `runs metrics` replaces `runs stats`. * The run's `error_message` field is now `error` in JSON output, including the final `watch_end` line of `runs watch`. * `runs start` without `--phone-id` now takes one of your dedicated phones, not a phone from the shared pool. Upgrade with `brew upgrade axilio` or `axilio upgrade`. ## Command map | Command group | Customer tasks | | - | - | | `axilio phones`, `axilio sessions` | Discover phones and manage phone sessions | | `axilio phone` | Observe and control the selected session's phone | | `axilio agents` | Create agents and manage their revisions | | `axilio runs` | Start, list, inspect, watch, cancel, and measure runs | | `axilio files` | Upload, list, download, push, and delete library files | | `axilio usage` | Read usage metrics and per-inference records | | `axilio billing` | Read the organization's balance and plan | | `axilio api-keys` | List, create, and delete API keys | | `axilio orgs` | List and select organizations for a browser session | Run `axilio --help` for flags and examples. Run `axilio help --html` to open the self-contained browser manual installed with the CLI. ## Structured output Use `-o json` for scripting: ```bash theme={null} axilio files list -o json ``` Most successful commands emit one JSON document. Two live commands use newline-delimited JSON because frames arrive over time: * `axilio sessions trace --follow -o json` emits one object per frame and finishes with a `trace_end` summary. * `axilio runs watch -o json` emits one object per frame and finishes with a `watch_end` summary. `axilio sessions start --phone-id --export` emits a shell assignment, not JSON. Bash and Zsh apply it with `eval`, PowerShell with `Invoke-Expression`. On Windows it prints PowerShell syntax by default; `--shell posix`, `--shell powershell`, or `--shell cmd` picks one explicitly. Progress, notes, and prompts go to standard error so standard output remains usable by scripts. ## Source The CLI is open source at [github.com/axilioai/cli](https://github.com/axilioai/cli). Homebrew packages are published through [axilioai/homebrew-tap](https://github.com/axilioai/homebrew-tap). The install scripts are [install.sh](https://github.com/axilioai/cli/blob/main/install.sh) and [install.ps1](https://github.com/axilioai/cli/blob/main/install.ps1). ## Next steps > [card] [SDK quickstart](/quickstart) > > Turn a proven CLI sequence into a Python or Go program. > [card] [Control reference](/driver/reference) > > Compare CLI commands with the Python and Go driver methods. # Allocate a phone Source: https://docs.axilio.ai/devices/allocate Start a session on a dedicated phone and release it safely. Start a session on any idle dedicated phone, or pass a `phone_id` to choose an exact device. > [!NOTE] > > If you allocate without a `phone_id` or a `pool`, you get a phone from the > shared pool, and you pay for its session time per second. The examples below > pass `pool="dedicated"` or a `phone_id` to use a rented phone. See > [Shared-pool and dedicated phones](/billing/overview#shared-pool-and-dedicated-phones). ## Start and release > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions) and choose your organization. > 2. Click **Allocate A Phone**. > 3. Choose **Use A Dedicated Phone**, then select **Any available** or one exact idle phone. > 4. Optionally add a session name, tags, or an idle timeout. > 5. Click **Allocate**. > 6. Click **Release** in the session list or **Deallocate** in the active session view when you finish. > [tab] CLI > > ```bash macOS and Linux theme={null} > axilio phones list > eval "$(axilio sessions start --phone-id ph_123 --export)" > > # Release by session ID. Clear the shell selection only once the stop succeeds. > axilio sessions stop "$AXILIO_SESSION" --yes && unset AXILIO_SESSION > ``` > > ```powershell Windows (PowerShell) theme={null} > axilio phones list > axilio sessions start --phone-id ph_123 --export | Invoke-Expression > > # Release by session ID. Clear the shell selection only once the stop succeeds. > axilio sessions stop $env:AXILIO_SESSION --yes > if ($LASTEXITCODE -eq 0) { Remove-Item Env:AXILIO_SESSION } > ``` > > `--export` prints a shell assignment that pins later CLI commands to the > new session. If you do not need that environment variable, run > `axilio sessions start --phone-id ph_123` and retain the returned session > ID for cleanup. > [tab] Python SDK > > Prefer the context manager because it releases the phone on every exit path: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > > with client.session("android", phone_id="ph_123") as driver: > print(driver.observe()) > ``` > > Manage the lifetime directly only when it must outlive a `with` block: > > ```python theme={null} > allocation = client.phones.allocate( > phone_type="android", > pool="dedicated", > ) > try: > print(allocation.phone_id, allocation.session_id) > # Connect to allocation.control_url and drive the phone. > finally: > client.phones.deallocate(allocation.phone_id) > ``` > > `pool="dedicated"` chooses any idle rented phone. Add > `phone_id="ph_123"` to pin the request to an exact phone. > [tab] Go SDK > > ```go theme={null} > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > Pool: platformgo.PhoneAllocateRequestPoolDedicated.Ptr(), > }) > if err != nil { > log.Fatal(err) > } > defer func() { > if _, err := api.Phones.Deallocate(ctx, &platformgo.PhonesDeallocateRequest{ > PhoneID: allocation.PhoneID, > }); err != nil { > log.Printf("release phone: %v", err) > } > }() > ``` > > `PoolDedicated` chooses any idle rented phone. Add > `PhoneID: platformgo.String("ph_123")` to pin the request. > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > * [**Release a phone**](/api-reference/rest/phones/release-a-phone): `POST /phones/{phone_id}:deallocate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > ALLOCATION=$(curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"phone_type":"android","pool":"dedicated"}') > > PHONE_ID=$(printf '%s' "$ALLOCATION" | jq -r '.phone_id') > printf '%s\n' "$ALLOCATION" | jq > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/phones/$PHONE_ID:deallocate" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` The Dashboard, generated SDK allocation methods, and REST API support **Any available** dedicated selection. The CLI and Python `client.session()` helper use the exact-phone path shown above, so give them a dedicated `phone_id`. > [!WARNING] > > Direct allocation is a lease. Release the phone when you finish so another > session can use it. Dedicated-phone rental charges are separate from session > lifetime. ## Allocation options The public API can also set: * `name` and `tags` to organize sessions * `recording` to record video of the session (off by default) * `telemetry` to enable or suppress both live and retained Telemetry for this directly allocated session * `capture` to add media produced on the phone to [Files](/files/overview) * `live_view` to configure the hosted viewer * `ttl.idle_timeout_seconds` to release an unattended phone Telemetry, file capture, and interactive token-authenticated live view default to enabled; recording defaults to off. A session name uses 1 to 64 letters, numbers, dots, hyphens, or underscores and must be unique among active sessions. An allocation accepts up to 50 tags; tag keys are limited to 40 UTF-8 bytes and values to 128 UTF-8 bytes. The Python `client.session()` helper intentionally exposes the common interactive path. Use `client.phones.allocate()` when you need the full option set. ## Next steps > [card] [Discover phones](/devices/discover) > > Find an idle dedicated phone ID. > [card] [Session lifecycle](/devices/timeouts) > > Configure idle timeout and inspect active or completed sessions. > [card] [Live view](/observability/live-view) > > Watch or interact with an active phone. > [card] [Telemetry](/telemetry/overview) > > Follow an active session and read its retained trace. # Dedicated phones Source: https://docs.axilio.ai/devices/dedicated Rent one specific phone, keep its state, and use it without per-session phone-time charges. A **dedicated phone** is reserved for your organization for its rental period. It keeps its apps and device state between sessions. * Rent by day, week, or month. * Reuse the same `phone_id`. * Sessions on the phone add no phone-time charge. * Vision and other metered services remain separate usage. ## Rent or manage a phone Rentals are dashboard actions: 1. Open [**Phones**](https://app.axilio.ai/dashboard/axilio/phones). 2. Click **Rent A Phone**. 3. Choose a rental period and complete checkout. 4. Open [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), then choose **Dedicated Phones** to inspect or cancel a renewal. The public API, SDKs, and CLI do not start checkout, renew, or cancel a rental. ## Read rental subscriptions > [tab] Python SDK > > ```python theme={null} > rentals = client.billing.get_rental_subscriptions() > > for rental in rentals.subscriptions or []: > print(rental.phone_id, rental.status, rental.current_period_end_at) > ``` > [tab] Go SDK > > ```go theme={null} > rentals, err := api.Billing.GetRentalSubscriptions(ctx) > if err != nil { > log.Fatal(err) > } > for _, rental := range rentals.Subscriptions { > phoneID := "not assigned" > if rental.PhoneID != nil { > phoneID = *rental.PhoneID > } > fmt.Println(phoneID, rental.Status, rental.CurrentPeriodEndAt) > } > ``` > [tab] REST API > > REST API References: > > * [**List phone rentals**](/api-reference/rest/billing/list-phone-rentals): `GET /billing/phone-rental-subscriptions` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/billing/phone-rental-subscriptions \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Use your phone Find the ID with `axilio phones mine` or `client.phones.list(ownership="dedicated")`, then pin it: ```bash theme={null} axilio sessions start --phone-id ph_123 ``` The dashboard also lets you choose any idle dedicated phone or one exact phone. ## Next steps > [card] [Discover phones](/devices/discover) > > Inspect dedicated status and availability. > [card] [Billing](/billing/overview) > > Understand rentals, usage, balance, and invoices. # Discover phones Source: https://docs.axilio.ai/devices/discover Find an idle dedicated phone or inspect every phone your organization rents. Use the dedicated-phone inventory to choose the `phone_id` for a session. A phone can be allocated when its `status` is `active` and `current_session_id` is empty. The full inventory also shows phones already in a session and phones in every other status: | Status | What it means | Comes back | | - | - | - | | `active` | Ready to allocate. | | | `inactive` | The phone cannot be reached. | On its own, when it reconnects. | | `maintenance` | Planned work, such as an update or a wipe. | On its own, when the work finishes. | | `recovering` | The platform is repairing the phone automatically. | On its own, if the repair works. Otherwise it becomes `out_of_service`. | | `out_of_service` | The phone needs a repair at the rack. | After Axilio fixes it. | | `suspended` | The phone is on hold by an administrator. | When the hold is lifted. | Only `active` phones can be allocated. Treat any status you do not recognize as unavailable: new statuses may be added, and SDK code that switches over every value should keep a default branch. ## Find a phone > [tab] Dashboard > > Open [**Phones**](https://app.axilio.ai/dashboard/axilio/phones). Search or filter your > dedicated inventory, then choose **Allocate** on an idle phone. > [tab] CLI > > ```bash theme={null} > # Dedicated phones that can be allocated now. > axilio phones list > > # Complete dedicated inventory, including busy and offline phones. > axilio phones mine > axilio phones mine -o json > ``` > [tab] Python SDK > > ```python theme={null} > inventory = client.phones.list(ownership="dedicated") > > for phone in inventory.phones or []: > print( > phone.phone_id, > phone.nickname, > phone.status, > phone.current_session_id, > ) > ``` > [tab] Go SDK > > ```go theme={null} > inventory, err := api.Phones.List(ctx, &platformgo.PhonesListRequest{ > Ownership: platformgo.PhonesListRequestOwnershipDedicated, > }) > if err != nil { > log.Fatal(err) > } > for _, phone := range inventory.Phones { > fmt.Println(phone.PhoneID, phone.Status, phone.CurrentSessionID) > } > ``` > [tab] REST API > > REST API References: > > * [**List the organization's phones**](/api-reference/rest/phones/list-the-orgs-phones): `GET /phones` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/phones?ownership=dedicated" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > This endpoint returns the full rented inventory. Require `status=active` > and an empty `current_session_id`, then handle an allocation > conflict in case another session claims it first. You can use the **`phone_id`** when you [**Allocate a phone**](/devices/allocate) to select a specific phone for a session. # Phones and sessions Source: https://docs.axilio.ai/devices/overview Choose a dedicated phone, start a session, and release it when you finish. A **phone** is a real Android device reserved for your organization. A **session** is the period when someone in your organization holds active control of that phone. Dedicated phones keep their apps and device state between sessions. You can start an interactive session and drive a phone yourself, or let a [run](/agents/overview) create the session. For a run, the run ID is also its session ID. ## Before you allocate Open [**Phones**](https://app.axilio.ai/dashboard/axilio/phones) to rent a phone or inspect the dedicated phones your organization already rents. A phone must be idle and active before a new session can claim it. If you allocate without a `phone_id` or a `pool`, you get a phone from the shared pool instead, and you pay for its session time per second. See [Shared-pool and dedicated phones](/billing/overview#shared-pool-and-dedicated-phones). Availability is a snapshot. Another session can claim an idle phone before your allocation request reaches the service, so the allocation result is always the authority. ## Start a session > [tab] Dashboard > > Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions), click > **Allocate A Phone**, choose **Use A Dedicated Phone**, and select **Any > available** or one exact idle phone. > [tab] CLI > > ```bash theme={null} > axilio phones list > axilio sessions start --phone-id ph_123 > ``` > > `axilio phones list` returns the dedicated phones that can be allocated > now. Use `axilio phones mine` when you also need to see busy or offline > inventory. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > > with client.session("android", phone_id="ph_123") as driver: > screen = driver.observe() > # The phone is released when the block exits. > ``` > [tab] Go SDK > > ```go theme={null} > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > }) > if err != nil { > log.Fatal(err) > } > defer func() { > if _, err := api.Phones.Deallocate(ctx, &platformgo.PhonesDeallocateRequest{ > PhoneID: allocation.PhoneID, > }); err != nil { > log.Printf("release phone: %v", err) > } > }() > ``` > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > * [**Release a phone**](/api-reference/rest/phones/release-a-phone): `POST /phones/{phone_id}:deallocate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "phone_type": "android", > "phone_id": "ph_123", > "pool": "dedicated" > }' > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones/ph_123:deallocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > See [Allocate a phone](/devices/allocate) for a complete request and > cleanup sequence. ## What a session gives you An allocation returns a `phone_id`, `session_id`, `control_url`, and `telemetry_url`. It also returns `live_view_url` unless you disabled hosted live view: * `control_url` connects to the Device Control Protocol. * `telemetry_url` opens the session's [live Telemetry](/telemetry/live) event stream. It is omitted when `telemetry=false`. * `live_view_url` opens the hosted interactive viewer when live view is enabled. Treat control and Telemetry URLs, plus token-authenticated live-view URLs, as credentials. They are scoped to the session and stop accepting new connections after the session ends. An organization-authenticated live-view URL instead requires Axilio sign-in and membership in the session's organization. ## Next steps > [card] [Allocate a phone](/devices/allocate) > > Start and release a session through each supported interface. > [card] [Discover phones](/devices/discover) > > Find an idle dedicated phone or inspect your complete inventory. > [card] [Session lifecycle](/devices/timeouts) > > Inspect sessions and understand idle and connection timeouts. > [card] [Control a phone](/driver/overview) > > Use the mobile driver after allocation. > [card] [Get started with Telemetry](/telemetry/get-started) > > Follow the session's execution record live and after completion. # Sessions and timeouts Source: https://docs.axilio.ai/devices/timeouts Inspect sessions and distinguish idle timeout, connection timeout, and the maximum session duration. A session starts when Axilio allocates a phone. The common customer-visible boundaries are manual release, an optional idle timeout, and the platform's maximum session duration. Axilio also ends a session when its phone stops reporting for about five minutes. That session completes, and its `session.ended` [webhook](/webhooks/events) carries `end_reason` `device_lost`. ## Inspect sessions > [tab] Dashboard > > Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions) to filter active and > completed sessions. Open a row for its phone, source, agent, recording, > files, live view, and Telemetry. > [tab] CLI > > ```bash theme={null} > axilio sessions list --remote > axilio sessions list --history > axilio sessions get sess_123 > ``` > [tab] Python SDK > > ```python theme={null} > sessions = client.phones.list_sessions(limit=20) > session = client.phones.get_session("sess_123") > > print(session.status, session.phone_id, session.recording_status) > ``` > [tab] Go SDK > > ```go theme={null} > sessions, err := api.Phones.ListSessions(ctx, &platformgo.PhonesListSessionsRequest{ > Limit: platformgo.Int64(20), > }) > if err != nil { > log.Fatal(err) > } > > session, err := api.Phones.GetSession(ctx, &platformgo.PhonesGetSessionRequest{ > SessionID: "sess_123", > }) > if err != nil { > log.Fatal(err) > } > fmt.Println(sessions.Total, session.Status, session.PhoneID, session.RecordingStatus) > ``` > [tab] REST API > > REST API References: > > * [**List sessions**](/api-reference/rest/phones/list-sessions): `GET /phones/sessions` > * [**Get a session**](/api-reference/rest/phones/get-a-session): `GET /phones/sessions/{session_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/phones/sessions?limit=20" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/phones/sessions/sess_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Idle timeout `ttl.idle_timeout_seconds` closes a session after that many seconds without a live user connection. A connected control, live-view, or Telemetry viewer keeps the session active; after the last viewer disconnects, the idle clock resumes. A session that was never connected is idle from allocation time. Omit the value, or pass a nonpositive value, for no idle timeout. Positive values are clamped to 1–3,600 seconds. A session without an idle timeout still ends at the one-hour platform cap. > [tab] Dashboard > > Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions), click > **Allocate A Phone**, and enter **idle timeout (seconds)**. Leave it blank > for no idle timeout. > [tab] Python SDK > > ```python theme={null} > from axilio import PhoneSessionTtlOptions > > allocation = client.phones.allocate( > phone_type="android", > phone_id="ph_123", > ttl=PhoneSessionTtlOptions(idle_timeout_seconds=300), > ) > ``` > [tab] Go SDK > > ```go theme={null} > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > TTL: &platformgo.PhoneSessionTTLOptions{ > IdleTimeoutSeconds: platformgo.Int64(300), > }, > }) > if err != nil { > log.Fatal(err) > } > defer func() { > if _, err := api.Phones.Deallocate(ctx, &platformgo.PhonesDeallocateRequest{ > PhoneID: allocation.PhoneID, > }); err != nil { > log.Printf("release phone: %v", err) > } > }() > ``` > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "phone_type": "android", > "phone_id": "ph_123", > "pool": "dedicated", > "ttl": { > "idle_timeout_seconds": 300 > } > }' > ``` The CLI does not currently expose the idle-timeout option. A CLI-started session remains active until you stop it or the maximum duration is reached. ## Connection timeout is different Python's `open_timeout` controls how long the SDK waits to connect to the allocated phone. It does not set the session lifetime, wait for an app screen, or change a command deadline: ```python theme={null} with client.session("android", phone_id="ph_123", open_timeout=20) as driver: print(driver.observe()) ``` ## Release reliably Use `client.session()` when Python owns the whole lifetime. For direct allocations, put deallocation in `finally` or `defer`. With the CLI, run `axilio sessions stop --yes`. Closing a Dashboard tab does not release the phone. End the session with **Release** or **Deallocate**. Ending a session frees its control lease; it does not erase the dedicated phone's apps or state and does not end its rental. # Press a key Source: https://docs.axilio.ai/driver/buttons Press Enter to submit a form or trigger the keyboard's Go or Search action. Some steps need a key press instead of a tap. Enter is the named key for everyday use; see [Key](/driver/key) for the full set. > [tab] CLI > > ```bash theme={null} > axilio phone tap --query "the Search field" > axilio phone type "axilio" > axilio phone key enter > ``` > [tab] Python SDK > > ```python theme={null} > from axilio.drivers.mobile import Key > > driver.get_by_text("Search").fill("axilio") > driver.key_press(Key.ENTER) > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Search").Fill("axilio"); err != nil { > log.Fatal(err) > } > if err := driver.KeyPress(mobile.KeyEnter); err != nil { > log.Fatal(err) > } > ``` Key presses travel over the session's Device Control Protocol connection. They are not REST operations. To type characters into a field, use [Type](/driver/type) instead. ## Next steps > [card] [Type](/driver/type) > > Type text into a field. > [card] [Key reference](/driver/key) > > See the named-key constants in each SDK. # Client Source: https://docs.axilio.ai/driver/client Open a dedicated-phone driver with Python, or call the Argus API on your own screenshots and UI elements. Python's high-level `Client` authenticates Platform requests and opens phone sessions. ```python theme={null} from axilio.platform import Client client = Client() ``` The constructor reads `AXILIO_API_KEY`. See [Configuration](/reference/configuration) for every client option. ## `client.session()` Use the context manager so the dedicated phone is released on every exit path: ```python theme={null} with client.session("android", phone_id="ph_123") as driver: driver.get_by_text("Continue").tap() ``` | Parameter | Type | Default | Description | | - | - | - | - | | `phone_type` | `"android"` | `"android"` | Device kind. Android is the supported customer platform. | | `phone_id` | `str` | `None` | Dedicated phone to allocate. Pass this for customer-facing sessions. | | `open_timeout` | `float` | `10.0` | Seconds to wait while opening the DCP connection. | | `default_ocr_engine` | `str` | `None` | Session-wide OCR default; omission resolves to `free`. | | `default_model` | `str` | `None` | Session-wide semantic-vision model; omission lets the service choose. | The yielded [driver](/driver/reference) closes its connection and deallocates the phone when the block exits. An OCR engine or model set on a [locator](/driver/locator) overrides the session default. ## Call Argus on your own input The phone driver already uses Argus for screen understanding. Call the Argus API directly when the input is a screenshot or a list of UI elements that you provide, such as DOM nodes. Argus has one operation per kind of input, because a screenshot is answered with a box and a list of elements with one of those elements. The CLI does not expose a direct Argus command. > [!NOTE] > > The Python and Go clients for the Argus API don't cover these operations yet. > Until an SDK release does, call them over HTTP as shown below. Every > operation except the model list needs your API key in `X-Axilio-Api-Key`. Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). ### Locate a described target in a screenshot REST API References: * [**Screenshots: locate**](/api-reference/argus/screenshots-locate): `POST /api/v1/screenshots:locate` ```bash theme={null} jq -n \ --arg image "$IMAGE_B64" \ --arg query "the blue Continue button" \ '{image: $image, query: $query}' | curl --silent --show-error --fail-with-body \ --request POST \ --url https://argus.axilio.ai/api/v1/screenshots:locate \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data-binary @- ``` When `found` is true, the response carries either a pixel `bbox` or, if you sent OCR results as `texts`, a `matched_text_index` into that list. The request also accepts an optional `model`. ### Locate a described element among your UI elements REST API References: * [**Elements: locate**](/api-reference/argus/elements-locate): `POST /api/v1/elements:locate` ```bash theme={null} jq -n \ --arg query "the Sign in button" \ '{ query: $query, elements: [ {id: "13", role: "textbox", name: "Password"}, {id: "14", role: "button", name: "Sign in"} ] }' | curl --silent --show-error --fail-with-body \ --request POST \ --url https://argus.axilio.ai/api/v1/elements:locate \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data-binary @- ``` The response names one of your element IDs as `element_id`, or reports `found: false`. Each element can also carry `value`, pixel `bounds`, and a `platform` object of native attributes, and the request accepts an optional screenshot as `image`. ### Detect text and icons in a screenshot REST API References: * [**Screenshots: detect**](/api-reference/argus/screenshots-detect): `POST /api/v1/screenshots:detect` ```bash theme={null} jq -n --arg image "$IMAGE_B64" \ '{image: $image, inference_type: "combined", ocr_engine: "free"}' | curl --silent --show-error --fail-with-body \ --request POST \ --url https://argus.axilio.ai/api/v1/screenshots:detect \ --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ --header "Content-Type: application/json" \ --data-binary @- ``` Detection also accepts `confidence_threshold` and `nms_iou_threshold`. ### List supported models REST API References: * [**Models: list**](/api-reference/argus/list-models): `GET /api/v1/models` ```bash theme={null} curl --silent --show-error --fail-with-body \ --url https://argus.axilio.ai/api/v1/models ``` The catalog is public, so this request does not need an API key. Argus errors use the [problem details](/reference/errors#problem-details) shape, the same as the REST API. ## See also > [card] [Driver reference](/driver/reference) > > See every live phone action. > [card] [Models](/drivers/models) > > Understand model selection and pricing. # Element Source: https://docs.axilio.ai/driver/element Inspect one recognized text match: its text, position, and confidence. Python `screen.find_text()` and `screen.find_all_text()` return `Element` values; Go returns the corresponding `mobile.Element`. An element is data from one [observed screen](/driver/screen): what was recognized and where. It has no actions. To act on something, use a [locator](/driver/locator), or pass `element.center` to a coordinate action. > [tab] Python SDK > > ```python theme={null} > element = driver.observe().find_text("Sign in") > if element is not None: > print(element.center, element.confidence) > driver.tap(element.center) > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > element := screen.FindText("Sign in", false) > if element != nil { > fmt.Println(element.Center, element.Confidence) > if err := driver.Tap(element.Center); err != nil { > log.Fatal(err) > } > } > ``` An element's position is from the moment the screen was observed. If the screen may have changed since, tap with a locator instead, which finds the target again at the moment it acts. The CLI prints the same element data in table or JSON output. There is no REST element resource. ## Fields | Python | Go | Description | | - | - | - | | `element.center` | `element.Center` | Tap point at the element's center. | | `element.bbox` | `element.BBox` | Bounding box in screen pixels. | | `element.text` | `element.Text` | Recognized text, when present. | | `element.confidence` | `element.Confidence` | Match confidence from `0` to `1`. | | `element.source` | `element.Source` | How the element was found. Always `ocr`: elements come from text recognition. | ## See also > [card] [Locator](/driver/locator) > > Find a target and act on it in one call. > [card] [Screen](/driver/screen) > > Query all elements from one observation. # Find Source: https://docs.axilio.ai/driver/find Point at something on the screen by visible text or a natural-language description. Point at a target with a [locator](/driver/locator) before you act on it. Use visible text when a label exists and a description when the target is easier to describe. Building a locator sends nothing: each action finds the target on the screen at that moment and waits until it is ready, up to 5 seconds by default. The examples assume you already selected a [dedicated-phone session](/devices/allocate). ## Find by visible text Text matching is read by OCR. By default the match is a case-insensitive substring. Pass `exact=True` (Go: `mobile.Exact()`) for a case-sensitive exact match instead. > [tab] CLI > > ```bash theme={null} > axilio phone find-text "Continue" -o json > axilio phone find-text "Continue" --exact -o json > ``` > > `find-text` searches one snapshot of the screen. A miss succeeds with > `null` in JSON output. > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Continue").tap() > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Continue").Tap(); err != nil { > log.Fatal(err) > } > ``` To check whether something is on screen without waiting, use [`count()`](/driver/locator#locator-count). It reports the matches right now, zero included: > [tab] Python SDK > > ```python theme={null} > banner = driver.get_by_text("Allow notifications") > if banner.count() > 0: > driver.get_by_text("Not now").tap() > ``` > [tab] Go SDK > > ```go theme={null} > n, err := driver.GetByText("Allow notifications").Count() > if err != nil { > log.Fatal(err) > } > if n > 0 { > if _, err := driver.GetByText("Not now").Tap(); err != nil { > log.Fatal(err) > } > } > ``` ## Find every text match Search one [observed screen](/driver/screen) for a list of matches. Use a substring for a simple list or a regular expression for structured text. The two criteria are mutually exclusive. > [tab] CLI > > ```bash theme={null} > axilio phone find-all-text "Notification" -o json > axilio phone find-all-text --pattern '\$[0-9]+\.[0-9]{2}' -o json > ``` > > With neither criterion, `find-all-text` returns every OCR text element. Add > `--ocr-engine premium` for a harder screen. > [tab] Python SDK > > ```python theme={null} > screen = driver.observe() > for item in screen.find_all_text(contains="Notification"): > print(item.text) > > prices = screen.find_all_text(pattern=r"\$\d+\.\d{2}") > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > items, err := screen.FindAllText("Notification", "") > if err != nil { > log.Fatal(err) > } > for _, item := range items { > fmt.Println(item.Text) > } > ``` ## Find by description A description uses a billable [vision model](/drivers/models). When the target never appears, the action fails with a timeout. > [tab] CLI > > ```bash theme={null} > axilio phone find "the search icon" -o json > ``` > > `find` waits up to `--timeout` (10 seconds by default). A target that never > appears exits with the timeout status (`5`). Use > `axilio phone tap --query "the search icon"` to find and tap in one command. > [tab] Python SDK > > ```python theme={null} > from axilio.drivers.mobile import ActionTimeoutError > > try: > driver.locator(query="the search icon").tap() > except ActionTimeoutError: > print("search icon not found") > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.Locator(mobile.Query("the search icon")).Tap(); err != nil { > if mobile.IsActionTimeout(err) { > log.Print("search icon not found") > return > } > log.Fatal(err) > } > ``` Review individual inference charges in [Usage](/usage/overview). Prefer text when a stable visible label exists. ## Narrow a match When the locator reads the screen (accessibility off, or `strategy="vision"`) and text matches several elements, `tap`, `fill` and `press` raise `ActionTimeoutError` and list the matches, because the first one may be behind a dialog. Pick one by position, by matching the whole text, by what contains it, or by a description: > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Add to cart").nth(1).tap() > > driver.get_by_text("Save", exact=True).tap() > > card = driver.get_by_text("Card").has(driver.get_by_text("Free shipping")) > card.first().tap() > > driver.get_by_text("Delete").filter(query="the red one").tap() > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Add to cart").Nth(1).Tap(); err != nil { > log.Fatal(err) > } > > if _, err := driver.GetByText("Save", mobile.Exact()).Tap(); err != nil { > log.Fatal(err) > } > > card := driver.GetByText("Card").Has(driver.GetByText("Free shipping")) > if _, err := card.First().Tap(); err != nil { > log.Fatal(err) > } > ``` `nth` counts from zero in reading order. Plain text is an OCR match, and `nth` on it picks from those matches. Anything more, such as a description, `within`, `has`, or `filter`, resolves with one vision-model call. ## Choose the OCR engine and model > [tab] CLI > > ```bash theme={null} > axilio phone find "the buy button" --ocr-engine premium --model anthropic/claude-haiku-4.5 > ``` > > `find`, `find-all-text`, `observe`, and `tap --query` accept an OCR-engine > override. `find-text` and `wait-for` do not. > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Sign in", ocr_engine="premium").tap() > driver.locator( > query="the buy button", > model="anthropic/claude-haiku-4.5", > ).tap() > ``` > > Set defaults for one dedicated-phone session: > > ```python theme={null} > with client.session( > "android", > phone_id="ph_123", > default_ocr_engine="premium", > default_model="anthropic/claude-haiku-4.5", > ) as driver: > driver.get_by_text("Sign in").tap() > driver.locator(query="the buy button").tap() > ``` > [tab] Go SDK > > ```go theme={null} > driver := mobile.ConnectRemote( > controlURL, > mobile.WithDefaultOCREngine("premium"), > mobile.WithDefaultModel("anthropic/claude-haiku-4.5"), > ) > defer driver.Close() > > buy := driver.Locator( > mobile.Query("the buy button"), > mobile.Model("anthropic/claude-haiku-4.5"), > ) > if _, err := buy.Tap(); err != nil { > log.Fatal(err) > } > ``` Locator actions run on the phone side over DCP `Locator.*` methods. The `find-text` and `find-all-text` commands and the `Screen` queries search one `Screen.observe` snapshot. None of these live phone operations is a REST endpoint. ## Next steps > [card] [Tap](/driver/tap) > > Act on what you found. > [card] [Wait](/driver/wait) > > Wait for a target before acting. > [card] [Locator reference](/driver/locator) > > Every locator action and refinement. > [card] [Models](/drivers/models) > > Choose a vision model and understand inference pricing. # Key Source: https://docs.axilio.ai/driver/key Use the named-key constants, Enter and Caps Lock, from the CLI or either SDK. Enter is the named key for everyday use. Caps Lock exists to clear a phone whose Caps Lock is stuck on. > [tab] CLI > > ```bash theme={null} > axilio phone key enter > ``` > [tab] Python SDK > > ```python theme={null} > from axilio.drivers.mobile import Key > > driver.key_press(Key.ENTER) > ``` > [tab] Go SDK > > ```go theme={null} > if err := driver.KeyPress(mobile.KeyEnter); err != nil { > log.Fatal(err) > } > ``` | Interface | Constant or value | | - | - | | CLI | `enter`, `capslock` | | Python | `Key.ENTER` (`"enter"`), `Key.CAPSLOCK` (`"capslock"`) | | Go | `mobile.KeyEnter` (`"enter"`), `mobile.KeyCapsLock` (`"capslock"`) | Use Enter to submit a form or trigger the on-screen keyboard's **Go** or **Search** action. To enter characters, use [Type](/driver/type). This action maps to DCP `Keyboard.keyPress`; it is not a REST operation. ## See also > [card] [Keys guide](/driver/buttons) > > Focus a field, type, and press Enter. > [card] [Driver reference](/driver/reference) > > See every driver action. # Locator reference Source: https://docs.axilio.ai/driver/locator Find a target on the screen and act on it in one call: actions, queries, refinements, and results. A **locator** describes a target: visible text, or a plain-language description. Create one with [`driver.get_by_text()`](/driver/reference#driver-get_by_text) or [`driver.locator()`](/driver/reference#driver-locator). Building a locator sends nothing. Each action finds the target on the screen at that moment, waits until it can act, then acts, all in one round trip. ```python theme={null} from axilio.platform import Client with Client().session("android", phone_id="ph_123") as driver: driver.get_by_text("Search").fill("axilio") driver.locator(query="the first result").tap() ``` > [!NOTE] > > Plain text resolves by OCR, and `nth` on it picks from those matches. A > description, or a refinement such as `within` or `has`, resolves with one > [vision-model](/drivers/models) call. Every action waits up to 5 seconds by > default; pass `timeout` in seconds, up to 60. When the target never becomes > ready, the call raises `ActionTimeoutError`, which is also a built-in > `TimeoutError`. The Go names are in the map at the end. > [!WARNING] > > When the locator reads the screen (accessibility off, or `strategy="vision"`), > an action on text that matches several elements raises > `ActionTimeoutError` instead of acting on one. OCR reads text behind a dialog > too, so the first match may not be the one you can see. The error lists each > match with its index. Use that index with [`nth`](#locator-nth), or narrow the > locator with `exact=True` or more specific text. `tap`, `fill` and `press` > behave this way. Calls that only read the screen, such as `text`, still report > the first match. ## Actions ### `locator.tap()` Wait for the target, then tap its center. ```python theme={null} driver.get_by_text("Continue").tap(timeout=10) ``` **Returns** [`LocatorResult`](#locatorresult). ### `locator.fill()` Wait for the target, tap it to focus, then type `text`. ```python theme={null} driver.get_by_text("Search").fill("agentic infrastructure") ``` | Parameter | Type | Default | Description | | - | - | - | - | | `text` | `str` | required | Text to type. Same limits as [`driver.type_text()`](/driver/reference#driver-type_text). | | `timeout` | `float` | `5` | Seconds to wait for the target. | **Returns** [`LocatorResult`](#locatorresult). ### `locator.press()` Wait for the target, focus it, then press a named [key](/driver/key). ```python theme={null} from axilio.drivers.mobile import Key driver.get_by_text("Search").press(Key.ENTER) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `key` | `str` | required | A [`Key`](/driver/key) constant. | | `timeout` | `float` | `5` | Seconds to wait for the target. | **Returns** [`LocatorResult`](#locatorresult). ### `locator.wait_for()` Wait until the target is visible, or until it is gone, without acting on it. ```python theme={null} driver.get_by_text("Loading").wait_for(state="hidden", timeout=30) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `state` | `"visible" \| "hidden"` | `"visible"` | The state to wait for. | | `timeout` | `float` | `5` | Seconds to wait. | **Returns** [`LocatorResult`](#locatorresult), or `None` for `state="hidden"`. ## Queries ### `locator.text()` Wait for the target, then return its recognized text. ```python theme={null} total = driver.locator(query="the order total").text() ``` **Returns** `str`. ### `locator.count()` Count the matches on the screen right now, zero included. This is the one call that never waits. It needs a plain text locator: one with a description, `within`, or `has` raises `InvalidArgsError`. ```python theme={null} if driver.get_by_text("Allow notifications").count() > 0: driver.get_by_text("Not now").tap() ``` **Returns** `int`. ### `locator.bounding_box()` Wait for the target, then return where it is. Use it for gestures a locator doesn't have, such as a long-press or a drag. ```python theme={null} box = driver.get_by_text("Photo").bounding_box().bounds print(box["x"], box["y"], box["width"], box["height"]) ``` **Returns** [`LocatorResult`](#locatorresult) with `bounds` set. A target found by a description can come back as a small box or a single point; its center is still where to act. ## Refinements Each refinement returns a new locator and keeps the OCR engine and model of the one it refines. ### `locator.nth()` The `n`th match in reading order, counting from zero. On plain text, this picks from the OCR matches with no vision-model call. An `n` past the last match finds nothing, so the call times out. ```python theme={null} driver.get_by_text("Add to cart").nth(1).tap() ``` **Returns** `Locator`. ### `locator.first()` The first match in reading order. Shorthand for `nth(0)`. On a screen with a dialog open, the first match can be text behind it, so check the index the error lists rather than adding `first()` by default. **Returns** `Locator`. ### `locator.within()` A match that is inside another locator's target. ```python theme={null} dialog = driver.locator(query="the confirmation dialog") driver.get_by_text("OK").within(dialog).tap() ``` **Returns** `Locator`. ### `locator.has()` A match that contains another locator's target. ```python theme={null} driver.get_by_text("Card").has(driver.get_by_text("Free shipping")).first().tap() ``` **Returns** `Locator`. ### `locator.filter()` Add a description on top of the locator's text. ```python theme={null} driver.get_by_text("Delete").filter(query="the red one").tap() ``` **Returns** `Locator`. ## LocatorResult What an action or query reports about the target it resolved. | Python | Go | Description | | - | - | - | | `result.resolved_by` | `result.ResolvedBy` | How the target was found: `ocr` or `vlm`. Empty when nothing was resolved. | | `result.bounds` | `result.Bounds` | Where the target was when the phone acted, in screen pixels. | | `result.took_ms` | `result.TookMs` | Time on the phone, waiting included, in milliseconds. | | `result.model_name` | `result.ModelName` | The vision model that resolved the target, when `resolved_by` is `vlm`. | ## Go method map Go builds locators with `driver.GetByText(text, opts...)` and `driver.Locator(opts...)`, using the options `mobile.Text`, `mobile.Query`, `mobile.Exact`, `mobile.Model`, and `mobile.OCREngine`. Actions take `mobile.WithTimeout(d)`. | Python | Go | | - | - | | `tap(timeout=...)` | `Tap(opts...)` | | `fill(text, timeout=...)` | `Fill(text, opts...)` | | `press(key, timeout=...)` | `Press(key, opts...)` | | `wait_for(state=..., timeout=...)` | `WaitFor(mobile.StateVisible \| mobile.StateHidden, opts...)` | | `text(timeout=...)` | `Text(opts...)` | | `count()` | `Count(opts...)` | | `bounding_box(timeout=...)` | `BoundingBox(opts...)` | | `nth(n)` / `first()` | `Nth(n)` / `First()` | | `within(other)` / `has(other)` | `Within(other)` / `Has(other)` | | `filter(query=...)` | `Filter(query)` | A Go timeout is reported by `mobile.IsActionTimeout(err)`. Locator actions use the DCP `Locator.*` methods; they are not REST operations. ## See also > [card] [Find](/driver/find) > > Choose between text and a description. > [card] [Driver](/driver/reference) > > Create locators and run coordinate actions. # Observe Source: https://docs.axilio.ai/driver/observe Read all recognized text and icons on the current screen. `observe` captures one screen and returns its recognized text, icons, dimensions, hash, and capture time. > [tab] CLI > > ```bash theme={null} > axilio phone observe -o json | jq '.texts | length' > ``` > > Add `--ocr-engine premium` when the screen needs the premium OCR engine. > [tab] Python SDK > > ```python theme={null} > screen = driver.observe() > print(len(screen.texts), "pieces of text") > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > fmt.Println(len(screen.Texts), "pieces of text") > ``` ## Search the snapshot locally Searching an observed snapshot does not capture the screen again. > [tab] CLI > > ```bash theme={null} > axilio phone observe -o json > screen.json > jq '.texts[] | select(.text | test("\\$[0-9]+\\.[0-9]{2}"))' screen.json > jq '.texts[] | select(.text | contains("Total"))' screen.json > ``` > [tab] Python SDK > > ```python theme={null} > screen = driver.observe() > prices = screen.find_all_text(pattern=r"\$\d+\.\d{2}") > total = screen.find_text("Total") > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > prices, err := screen.FindAllText("", `\$\d+\.\d{2}`) > if err != nil { > log.Fatal(err) > } > total := screen.FindText("Total", false) > fmt.Println(len(prices), total) > ``` See [Screen](/driver/screen) for the snapshot fields and local query rules. `observe` maps to DCP `Screen.observe`; it is not a REST operation. ## A picture instead? `observe` gives you structured text and icons. Use [Screenshot](/driver/screenshot) when you need PNG bytes. ## Next steps > [card] [Find](/driver/find) > > Search the live screen for one target. > [card] [Screenshot](/driver/screenshot) > > Save the current screen as an image. # How the driver works Source: https://docs.axilio.ai/driver/overview Observe a phone, find a target, act, and verify the next screen with the CLI or an SDK. The mobile driver controls the phone inside a session. Its reliable loop is: 1. Observe the current screen. 2. Point at a target with a locator, by visible text or a description. 3. Tap, type, swipe, or press a key. 4. Observe or wait for the expected result. A locator action finds its target on the screen at that moment and waits until it can act, up to 5 seconds by default, so most steps need no separate wait. Start with a [dedicated-phone session](/devices/allocate), then use the interface that fits your program. > [tab] CLI > > ```bash theme={null} > axilio phone observe > axilio phone tap --query "the search icon" > axilio phone tap --query "the Search field" > axilio phone type "axilio" > axilio phone key enter > axilio phone observe > ``` > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > > with client.session("android", phone_id="ph_123") as driver: > driver.locator(query="the search icon").tap() > driver.get_by_text("Search").fill("axilio") > driver.key_press("enter") > screen = driver.observe() > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.Locator(mobile.Query("the search icon")).Tap(); err != nil { > log.Fatal(err) > } > if _, err := driver.GetByText("Search").Fill("axilio"); err != nil { > log.Fatal(err) > } > if err := driver.KeyPress(mobile.KeyEnter); err != nil { > log.Fatal(err) > } > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > fmt.Println(len(screen.Texts)) > ``` > [!NOTE] > > Phone control uses the session's Device Control Protocol WebSocket. The CLI > and SDK drivers expose these actions; they are not REST endpoints. ## The basics > [card] [Find](/driver/find) > > Locate something by its text or by describing it. > [card] [Tap](/driver/tap) > > Tap a target or press and hold. > [card] [Type](/driver/type) > > Type into a field. > [card] [Swipe](/driver/swipe) > > Swipe, scroll, or drag. > [card] [Keys](/driver/buttons) > > Press Enter to submit a form or search. > [card] [Observe](/driver/observe) > > Read the whole screen at once. > [card] [Wait](/driver/wait) > > Wait for the screen to change before acting. > [card] [Screenshot](/driver/screenshot) > > Capture the current pixels as a PNG. ## Semantic targets and coordinates Use text or a natural-language query for a tappable target whenever possible. Coordinates are appropriate for gestures that move the whole screen or for a fixed point you derived from the current observation. Only tap, long-press, and swipe have raw coordinate forms. Finding, waiting, typing, key presses, observation, and screenshots use their own typed inputs. For complete Python signatures and the corresponding Go methods, see the [Driver](/driver/reference) and [Locator](/driver/locator) references. # Driver reference Source: https://docs.axilio.ai/driver/reference Python driver signatures, DCP mappings, and the corresponding Go methods. The **driver** is what [`client.session()`](/driver/client) hands you inside a session. It's the object every action runs on: finding, tapping, typing, swiping, and reading the screen. Methods that point at something return a [`Locator`](/driver/locator); reading the whole screen returns a [`Screen`](/driver/screen). > [!NOTE] > > The detailed signatures below use Python. The Go SDK exposes the same DCP > surface with Go naming and explicit duration arguments; see the method map at > the end. Live phone actions are not REST operations. ```python theme={null} from axilio.platform import Client with Client().session("android", phone_id="ph_123") as driver: driver.get_by_text("Continue").tap() ``` ## Connection and capabilities ### `driver.handshake()` Return the executor's protocol version, device descriptor, domains, and exact capability set. Gate optional behavior on these advertised capabilities. ```python theme={null} hello = driver.handshake() print(hello.protocol_version, hello.capabilities) ``` **Returns** `HandshakeResult`. ### `driver.device_info()` Return the phone's static DCP descriptor. ```python theme={null} device = driver.device_info() print(device.screen_width, device.screen_height) ``` **Returns** `DeviceInfo`. ### `driver.close()` Close the underlying control transport. `client.session()` calls this and deallocates its phone automatically when the context exits. ## Locating ### `driver.get_by_text()` Point at something by its visible text, read by OCR. Nothing is sent until you call an action on the result. ```python theme={null} driver.get_by_text("Sign in", exact=False, ocr_engine="premium").tap() ``` | Parameter | Type | Default | Description | | - | - | - | - | | `text` | `str` | required | The visible text to match. | | `exact` | `bool` | `False` | Require a case-sensitive exact match, not a substring. | | `model` | `str` | session default | A [vision model](/drivers/models) id, used when a refinement needs one. | | `ocr_engine` | `"free" \| "premium"` | session default | Text-reading engine. `premium` handles harder screens. | **Returns** [`Locator`](/driver/locator). ### `driver.locator()` Point at something by describing it in plain English, for when there's no text to match (an icon, an image). A description runs a [vision-language model](/drivers/models), so it costs more than text. ```python theme={null} driver.locator( query="the heart icon under the photo", model="anthropic/claude-haiku-4.5", ).tap() ``` | Parameter | Type | Default | Description | | - | - | - | - | | `query` | `str` | `None` | A plain-English description of the target (keyword-only). | | `text` | `str` | `None` | Visible text to match, as in `get_by_text()`. | | `exact` | `bool` | `None` | Require an exact text match. | | `model` | `str` | session default | A [vision model](/drivers/models) id. | | `ocr_engine` | `"free" \| "premium"` | session default | OCR engine used to ground the description. | **Returns** [`Locator`](/driver/locator). ## Tapping ### `driver.tap()` Tap an exact point on the screen. To tap something you located, call [`locator.tap()`](/driver/locator#locator-tap) instead. ```python theme={null} driver.tap({"x": 540, "y": 1180}) ``` **Returns** `None`. `coords` is a `{"x": int, "y": int}` dict. ### `driver.long_press()` Press and hold an exact point, for example to open a context menu. To hold a located target, read its position with [`locator.bounding_box()`](/driver/locator#locator-bounding_box). ```python theme={null} driver.long_press({"x": 540, "y": 1180}, duration_ms=1500) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `coords` | `dict` | required | `{"x": int, "y": int}` screen coordinates. | | `duration_ms` | `int` | `800` | How long to hold, in milliseconds. | **Returns** `None`. ## Typing ### `driver.type_text()` Type into the field that's already selected. To tap a field and type in one call, use [`locator.fill()`](/driver/locator#locator-fill). To press Enter (submit / search), use [`driver.key_press()`](#driver-key_press). ```python theme={null} driver.type_text("hello") ``` **Returns** `None`. ## Swiping ### `driver.swipe()` Swipe between two points. This is also how you scroll: swipe **up** (end higher than start) to move down a list. To drag one located target to another, see [Swipe](/driver/swipe#drag-one-element-to-another). ```python theme={null} driver.swipe( {"x": 540, "y": 1600}, {"x": 540, "y": 400}, duration_ms=300, ) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `start` | `dict` | required | `{"x": int, "y": int}` where the swipe begins. | | `end` | `dict` | required | `{"x": int, "y": int}` where it ends. | | `duration_ms` | `int` | `300` | Duration of the swipe, in milliseconds. | **Returns** `None`. ## Buttons ### `driver.key_press()` Press a named key. [`Key.ENTER`](/driver/key) submits a form or fires the on-screen keyboard's Go / Search. ```python theme={null} from axilio.drivers.mobile import Key driver.key_press(Key.ENTER) ``` **Returns** `None`. `key` is a string; see [`Key`](/driver/key) for the constants. ### `driver.press()` Press a named key against whatever has focus, through the same path as a locator action. To focus a specific target first, use [`locator.press()`](/driver/locator#locator-press). ```python theme={null} driver.press(Key.ENTER, timeout=5) ``` **Returns** [`LocatorResult`](/driver/locator#locatorresult) with no `bounds`, since nothing was located. ## Reading the screen ### `driver.observe()` Read everything on the screen at once, all text and icons, as a queryable snapshot you can search without going back to the phone. ```python theme={null} screen = driver.observe() prices = screen.find_all_text(pattern=r"\$\d+\.\d{2}") ``` | Parameter | Type | Default | Description | | - | - | - | - | | `ocr_engine` | `"free" \| "premium"` | session default | Text-reading engine for this observation. | **Returns** [`Screen`](/driver/screen). ### `driver.screenshot()` Take a picture of the screen as PNG image data, to save, log, or debug a run. ```python theme={null} picture = driver.screenshot() open("screen.png", "wb").write(picture) ``` **Returns** `bytes`: raw PNG data. ## Go method map Go constructs a driver with `mobile.ConnectRemote(controlURL, options...)`. Use `mobile.WithDefaultOCREngine`, `mobile.WithDefaultModel`, and `mobile.WithOpenTimeout` for connection-wide defaults. `Observe`, `Handshake`, and `DeviceInfo` take `mobile.WithOCREngine`. Locators take the options listed in the [Locator reference](/driver/locator#go-method-map). | Python | Go | | - | - | | `handshake()` | `Handshake(opts...)` | | `device_info()` | `DeviceInfo(opts...)` | | `observe(ocr_engine=...)` | `Observe(opts...)` | | `get_by_text(text, exact=..., model=..., ocr_engine=...)` | `GetByText(text, opts...)` | | `locator(query=..., text=..., exact=..., model=..., ocr_engine=...)` | `Locator(opts...)` | | `tap(coords)` | `Tap(coords)` | | `long_press(coords, duration_ms=...)` | `LongPress(coords, durationMs)` | | `swipe(start, end, duration_ms=...)` | `Swipe(start, end, durationMs)` | | `type_text(text)` | `TypeText(text)` | | `key_press(key)` | `KeyPress(key)` | | `press(key, timeout=...)` | `Press(key, opts...)` | | `screenshot()` | `Screenshot()` | | `close()` | `Close()` | Go requires gesture duration arguments where Python supplies defaults. ## See also > [card] [Locator](/driver/locator) > > What `get_by_text()` and `locator()` return, and the actions on it. > [card] [Screen](/driver/screen) > > What `observe()` returns. > [card] [Client](/driver/client) > > Opening a session and calling Argus directly. > [card] [Key](/driver/key) > > Every device-button constant. # Screen Source: https://docs.axilio.ai/driver/screen Query one observed snapshot without capturing the phone again. `observe` returns a `Screen`: one snapshot of recognized text, icons, dimensions, hash, and capture time. Local screen queries do not make another device or inference request. > [tab] CLI > > ```bash theme={null} > axilio phone observe -o json > screen.json > jq '.texts[] | select(.text | contains("Sign in"))' screen.json > ``` > [tab] Python SDK > > ```python theme={null} > screen = driver.observe() > row = screen.find_text("Sign in") > prices = screen.find_all_text(pattern=r"\$\d+\.\d{2}") > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > row := screen.FindText("Sign in", false) > if row != nil { > fmt.Println(row.Text) > } > prices, err := screen.FindAllText("", `\$\d+\.\d{2}`) > if err != nil { > log.Fatal(err) > } > fmt.Println(len(prices)) > ``` ## Find one text element Python `screen.find_text(text, exact=False)` and Go `screen.FindText(text, exact)` return the first matching OCR element or an empty result. A non-exact match is a case-insensitive substring; an exact match is case-sensitive. ## Find all text elements Python `screen.find_all_text(contains=..., pattern=...)` and Go `screen.FindAllText(contains, pattern)` return every match. Choose either a substring or a regular expression, not both. With neither criterion, the method returns all recognized text. ## Fields | Python | Go | Description | | - | - | - | | `screen.texts` | `screen.Texts` | Recognized text elements. | | `screen.icons` | `screen.Icons` | Detected icons with geometry and confidence. | | `screen.width`, `screen.height` | `screen.Width`, `screen.Height` | Screen dimensions in pixels. | | `screen.captured_at` | `screen.CapturedAt` | When the snapshot was captured. | | `screen.hash` | `screen.Hash` | Content hash for comparing snapshots. | ## See also > [card] [Observe](/driver/observe) > > Capture and inspect the current screen. > [card] [Element](/driver/element) > > What one text match contains. # Screenshot Source: https://docs.axilio.ai/driver/screenshot Capture the current phone screen as PNG bytes. Use a screenshot to save, display, or debug the exact screen image. The result is a PNG. > [tab] CLI > > ```bash theme={null} > axilio phone screenshot --out screen.png > ``` > > The default path is `screenshot.png`. The CLI overwrites an existing output > file without confirmation. > [tab] Python SDK > > ```python theme={null} > picture = driver.screenshot() > with open("screen.png", "wb") as output: > output.write(picture) > ``` > [tab] Go SDK > > ```go theme={null} > picture, err := driver.Screenshot() > if err != nil { > log.Fatal(err) > } > if err := os.WriteFile("screen.png", picture, 0o600); err != nil { > log.Fatal(err) > } > ``` `screenshot` maps to DCP `Screen.screenshot`; it is not a REST operation. ## Picture or structured screen? * Use **screenshot** for the pixels as PNG data. * Use [**observe**](/driver/observe) for recognized text, icons, dimensions, and a queryable snapshot. > [!TIP] > > Capture a screenshot when a step fails. It shows the exact state your > automation encountered. ## Next steps > [card] [Observe](/driver/observe) > > Get structured text and icons. > [card] [Driver overview](/driver/overview) > > Return to the complete control loop. # Swipe Source: https://docs.axilio.ai/driver/swipe Swipe between screen coordinates or drag one located element to another. A swipe moves between two points. Swipe upward to move down a list; swipe downward to move up. > [tab] CLI > > ```bash theme={null} > axilio phone swipe 540 1600 540 400 --duration-ms 300 > ``` > > The CLI swipe command is coordinate-only. > [tab] Python SDK > > ```python theme={null} > driver.swipe( > {"x": 540, "y": 1600}, > {"x": 540, "y": 400}, > duration_ms=300, > ) > ``` > [tab] Go SDK > > ```go theme={null} > err := driver.Swipe( > mobile.Coords{X: 540, Y: 1600}, > mobile.Coords{X: 540, Y: 400}, > 300, > ) > if err != nil { > log.Fatal(err) > } > ``` The CLI and Python defaults are 300 milliseconds. Go requires the duration argument explicitly. ## Drag one element to another Read both positions with [`bounding_box()`](/driver/locator#locator-bounding_box), then swipe between their centers: > [tab] Python SDK > > ```python theme={null} > def center(box): > return {"x": box["x"] + box["width"] // 2, "y": box["y"] + box["height"] // 2} > > card = driver.get_by_text("Card").bounding_box().bounds > archive = driver.get_by_text("Archive").bounding_box().bounds > driver.swipe(center(card), center(archive), duration_ms=300) > ``` > [tab] Go SDK > > ```go theme={null} > card, err := driver.GetByText("Card").BoundingBox() > if err != nil { > log.Fatal(err) > } > archive, err := driver.GetByText("Archive").BoundingBox() > if err != nil { > log.Fatal(err) > } > if err := driver.Swipe(card.Bounds.Center(), archive.Bounds.Center(), 300); err != nil { > log.Fatal(err) > } > ``` `bounding_box()` waits for each target like any locator action. The drag itself is DCP `Touch.swipe`. Live swipe actions are not REST operations. ## Next steps > [card] [Tap](/driver/tap) > > Tap semantic or coordinate targets. > [card] [Observe](/driver/observe) > > Verify the screen after a swipe. # Tap Source: https://docs.axilio.ai/driver/tap Tap a semantic target or an exact screen coordinate, or press and hold. Prefer a text or described target when one exists. Use raw coordinates only when the action belongs to a fixed point on the current screen. > [tab] CLI > > ```bash theme={null} > axilio phone tap --query "the Continue button" > ``` > > `--query` finds the target with semantic vision, waits up to 5 seconds for > it to appear, and taps its center. You can add `--ocr-engine` or `--model` > overrides. > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Continue").tap() > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Continue").Tap(); err != nil { > log.Fatal(err) > } > ``` The tap waits up to 5 seconds for the target to appear. Pass `timeout=` (Go: `mobile.WithTimeout`) for a slower screen, up to 60 seconds. ## Tap an exact point Coordinates start at `(0, 0)` in the screen's top-left corner. > [tab] CLI > > ```bash theme={null} > axilio phone tap 540 1180 > ``` > [tab] Python SDK > > ```python theme={null} > driver.tap({"x": 540, "y": 1180}) > ``` > [tab] Go SDK > > ```go theme={null} > if err := driver.Tap(mobile.Coords{X: 540, Y: 1180}); err != nil { > log.Fatal(err) > } > ``` ## Press and hold > [tab] CLI > > ```bash theme={null} > axilio phone long-press 540 1180 --duration-ms 1500 > ``` > > The CLI long-press command is coordinate-only. The CLI and Python default > to 800 milliseconds; Go requires the duration explicitly. > [tab] Python SDK > > ```python theme={null} > box = driver.get_by_text("Photo").bounding_box().bounds > center = {"x": box["x"] + box["width"] // 2, "y": box["y"] + box["height"] // 2} > driver.long_press(center, duration_ms=1500) > ``` > [tab] Go SDK > > ```go theme={null} > photo, err := driver.GetByText("Photo").BoundingBox() > if err != nil { > log.Fatal(err) > } > if err := driver.LongPress(photo.Bounds.Center(), 1500); err != nil { > log.Fatal(err) > } > ``` A locator has no long-press action, so read the target's position with [`bounding_box()`](/driver/locator#locator-bounding_box) and hold its center. A locator tap uses DCP `Locator.tap`. Coordinate taps and long-presses use `Touch.tap` and `Touch.longPress`. None of them is a REST operation. ## Next steps > [card] [Find](/driver/find) > > Locate a safe target before acting. > [card] [Type](/driver/type) > > Enter text after focusing a field. > [card] [Swipe](/driver/swipe) > > Scroll or move between two points. # Type Source: https://docs.axilio.ai/driver/type Type text into the focused field on a phone. Focus a field, then type into it. A locator's `fill` does both: it taps the field, then types. > [tab] CLI > > ```bash theme={null} > axilio phone tap --query "the Search field" > axilio phone type "agentic infrastructure" > ``` > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Search").fill("agentic infrastructure") > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Search").Fill("agentic infrastructure"); err != nil { > log.Fatal(err) > } > ``` If the field is already focused, type directly: > [tab] CLI > > ```bash theme={null} > axilio phone type "hello" > ``` > [tab] Python SDK > > ```python theme={null} > driver.type_text("hello") > ``` > [tab] Go SDK > > ```go theme={null} > if err := driver.TypeText("hello"); err != nil { > log.Fatal(err) > } > ``` Text uses a US-layout keyboard, and one request is limited to 4,096 UTF-8 bytes. The phone types printable ASCII. It skips unsupported characters, including emoji, when the request is otherwise within the limit. To submit a form or trigger the keyboard's **Go** or **Search** action, press [Enter](/driver/buttons) after typing. `fill` uses DCP `Locator.fill`, and typing into the focused field uses `Keyboard.typeText`. Neither is a REST operation. ## Next steps > [card] [Keys](/driver/buttons) > > Press Enter after typing. > [card] [Find](/driver/find) > > Find the field to type into. # Wait Source: https://docs.axilio.ai/driver/wait Wait for the screen to reach the state you expect instead of sleeping for a fixed delay. Apps take time to load and change. Every [locator](/driver/locator) action already waits up to 5 seconds for its target, so most steps need no separate wait. Wait explicitly when you need longer, or when you want to hold until something appears or disappears without acting on it. ## Wait for text to appear > [tab] CLI > > ```bash theme={null} > axilio phone wait-for "Welcome" --timeout 30s -o json > ``` > > Add `--exact` for a case-sensitive exact match. A timeout returns exit > status `5`. > [tab] Python SDK > > ```python theme={null} > welcome = driver.get_by_text("Welcome") > welcome.wait_for(timeout=30) > welcome.tap() > ``` > [tab] Go SDK > > ```go theme={null} > welcome := driver.GetByText("Welcome") > if _, err := welcome.WaitFor(mobile.StateVisible, mobile.WithTimeout(30*time.Second)); err != nil { > log.Fatal(err) > } > if _, err := welcome.Tap(); err != nil { > log.Fatal(err) > } > ``` ## Wait for text to disappear > [tab] CLI > > ```bash theme={null} > axilio phone wait-for "Loading" --gone --timeout 30s > ``` > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Loading").wait_for(state="hidden", timeout=30) > ``` > [tab] Go SDK > > ```go theme={null} > if _, err := driver.GetByText("Loading").WaitFor(mobile.StateHidden, mobile.WithTimeout(30*time.Second)); err != nil { > log.Fatal(err) > } > ``` A wait can last up to 60 seconds per call. When the time runs out, Python raises `ActionTimeoutError` (also a built-in `TimeoutError`) and Go returns an error that `mobile.IsActionTimeout` matches. Any locator works here, including a description: `driver.locator(query="the order confirmation").wait_for()`. ## Wait for a custom condition For a condition a locator can't express, observe the screen in a loop: > [tab] Python SDK > > ```python theme={null} > import time > > deadline = time.monotonic() + 30 > while driver.observe().find_text("Done") is None: > if time.monotonic() > deadline: > raise TimeoutError("Done never appeared") > time.sleep(0.3) > ``` > [tab] Go SDK > > ```go theme={null} > deadline := time.Now().Add(30 * time.Second) > for { > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > if screen.FindText("Done", false) != nil { > break > } > if time.Now().After(deadline) { > log.Fatal("Done never appeared") > } > time.Sleep(300 * time.Millisecond) > } > ``` Locator waits run on the phone side as one DCP `Locator.waitFor` call, so they continue as soon as the screen is ready. The custom loop calls `Screen.observe` each time. Neither is a REST operation. ## Next steps > [card] [Find](/driver/find) > > Point at text or a described target. > [card] [Errors](/reference/errors) > > Recover from timeouts and other control failures. # Models Source: https://docs.axilio.ai/drivers/models Every model the driver can use — the Axilio model line and the vision-language models — with slugs, pricing, and how to select each one. **Argus** is Axilio's vision model, and it is how the driver sees the screen. It runs two families of models. The **Axilio models** read text, icons, and layout for `get_by_text()` and `observe()`. The **vision-language models** understand a description for `locator(query=...)`. The public catalog lists both families with live pricing. > [tab] CLI > > The CLI does not have a model-catalog command. Use the public REST > operation, then pass the chosen engine or model to `axilio phone`. > [tab] REST API > > REST API References: > > * [**Models: list**](/api-reference/argus/list-models): `GET /api/v1/models` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > https://argus.axilio.ai/api/v1/models > ``` > > The catalog is public; this request does not need an API key. > [!NOTE] > > The Python and Go clients for the Argus API are being regenerated for its > new operations. Until that SDK release, call the REST operations on this page > directly. ## Axilio models The Axilio line comes in two tiers: **Lite** is free, and **Pro** reads harder screens such as small fonts and dense layouts for a per-call price. You do not pass these model IDs to the driver. Pick the tier with an OCR-engine option: > [tab] CLI > > ```bash theme={null} > axilio phone observe --ocr-engine premium > axilio phone find-all-text "Sign in" --ocr-engine premium > ``` > > `phone find-text` and `phone wait-for` use their default OCR engine and do > not accept an engine override. > [tab] Python SDK > > ```python theme={null} > driver.get_by_text("Sign in", ocr_engine="premium").tap() # Pro > ``` > > Leave `ocr_engine` off to use Lite, the default. > [tab] Go SDK > > ```go theme={null} > signIn := driver.GetByText("Sign in", mobile.OCREngine("premium")) > if _, err := signIn.Tap(); err != nil { > log.Fatal(err) > } > ``` To use a tier for a whole SDK driver instead of every call, set it once: > [tab] Python SDK > > ```python theme={null} > with client.session( > "android", > phone_id="ph_123", > default_ocr_engine="premium", > ) as driver: > driver.get_by_text("Sign in").tap() # Pro, no per-call option needed > ``` > [tab] Go SDK > > ```go theme={null} > driver := mobile.ConnectRemote( > controlURL, > mobile.WithDefaultOCREngine("premium"), > ) > defer driver.Close() > ``` The IDs below are what you see on [usage rows](/usage/overview) and in the pricing list: | Model id | What it is | How you get it | | - | - | - | | `axilio/argus-detect-1` | Icon and element detection | Included in every screen read | | `axilio/argus-ocr-lite-1` | Text reading, free tier | `ocr_engine="free"` on a text-only read | | `axilio/argus-ocr-pro-1` | Text reading, premium | `ocr_engine="premium"` on a text-only read | | `axilio/argus-vision-lite-1` | Text + icons, free tier | The default for `get_by_text` / `observe` | | `axilio/argus-vision-pro-1` | Text + icons, premium | `ocr_engine="premium"` | ## Vision-language models Describing something in plain English, such as `locator(query="the heart icon")`, runs a vision-language model. So does refining a text locator with `within`, `has`, or `filter`. Pass a catalog ID to choose one: > [tab] CLI > > ```bash theme={null} > axilio phone find "the buy button" --model anthropic/claude-haiku-4.5 > ``` > [tab] Python SDK > > ```python theme={null} > driver.locator( > query="the buy button", > model="anthropic/claude-haiku-4.5", > ).tap() > ``` > [tab] Go SDK > > ```go theme={null} > buy := driver.Locator( > mobile.Query("the buy button"), > mobile.Model("anthropic/claude-haiku-4.5"), > ) > if _, err := buy.Tap(); err != nil { > log.Fatal(err) > } > ``` Python and Go can also set a driver default: > [tab] Python SDK > > ```python theme={null} > with client.session( > "android", > phone_id="ph_123", > default_model="anthropic/claude-haiku-4.5", > ) as driver: > driver.locator(query="the buy button").tap() > ``` > [tab] Go SDK > > ```go theme={null} > driver := mobile.ConnectRemote( > controlURL, > mobile.WithDefaultModel("anthropic/claude-haiku-4.5"), > ) > defer driver.Close() > ``` Leave `model=` off and Argus picks a sensible default. An id that isn't in the list below is rejected before anything is billed. Vision-language calls are billed per token: Prices are pulled live, so they're never stale. See [Usage](/usage/overview) to track what you actually spend. ## Calling Argus directly The driver runs Argus for you on the live screen. If you have your own image or UI elements to analyze, call the Argus API directly. It uses the same models and has one operation per input: * [**Screenshots: detect**](/api-reference/argus/screenshots-detect) finds icons and reads text in an image. * [**Screenshots: locate**](/api-reference/argus/screenshots-locate) finds a described element in an image. * [**Elements: locate**](/api-reference/argus/elements-locate) picks the described element from UI elements you send, such as DOM nodes. See [Call Argus on your own input](/driver/client#call-argus-on-your-own-input) for examples. ## Next steps > [card] [Find](/driver/find) > > Where the engine and model options plug in. > [card] [Usage](/usage/overview) > > See what your vision calls cost. # Drivers Source: https://docs.axilio.ai/drivers/overview A driver is how you control a device once the Platform gives you one. Each kind of device has its own. A **driver** is how you control a device once the [Platform](/introduction) hands you one. Each kind of device has its own driver, with controls that fit it. > [card] [Mobile](/driver/overview) > > Control an Android phone. Find things on the screen, tap, type, and swipe. > [card] Desktop > > Coming soon. > [card] Tablet > > Coming soon. ## How drivers fit in * The **[Platform](/introduction)** gives you a device and manages it, the same for every kind of device. * A **driver** controls that device. The controls match the device: a phone is a touchscreen, so the [Mobile driver](/driver/overview) is tap, type, and swipe. Get started with the [Mobile driver](/driver/overview). # Move files Source: https://docs.axilio.ai/files/move-files Upload a file, push it to a phone, download it locally, rename supported uploads, and delete it from the library. Moving a file is a two-part operation: 1. **Upload** stores the local bytes once in your organization's library. 2. **Push** creates a delivery from that library file to a phone. You can push the same file to several phones without uploading it again. You can also use a one-shot send helper when you do not need to retain the two steps separately. > [!WARNING] > > Both the library and phone delivery cap a file at 100 MiB. An upload larger > than that is rejected before any bytes transfer, and a one-shot Python or Go > send refuses an oversized file without uploading it. Files stored under the > earlier 1 GiB library limit stay in the library, but a push to a phone still > refuses anything over 100 MiB. ## Upload and push > [tab] Dashboard > > Open [**Files**](https://app.axilio.ai/dashboard/axilio/files) for your > organization and choose **Upload Files**, drag files onto the page, or upload > from the **Files** tool during a session. > > During an active session, open the **Files** tool, select a library file, > and choose **Send**. The delivery reports progress and reaches `delivered` > or `failed` after the phone responds. > [tab] CLI > > ```bash theme={null} > axilio files upload ./photo.jpg > axilio files list --source upload > axilio files push --phone-id --wait > ``` > > `files upload` infers the filename and MIME type. Use `--filename` or > `--mime-type` to override them. `files push` returns after dispatch unless > you add `--wait`; then inspect whether the final status is `delivered` or > `failed`. > > Use `axilio phone send ./photo.jpg` when the selected session should upload > and deliver the file in one command. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > > stored = client.files.upload("./photo.jpg") > delivery = client.files.push( > "", > stored.id, > wait=True, > ) > > print(stored.id, delivery.status) > ``` > > `client.files.send("", "./photo.jpg", wait=True)` combines the > upload and push. A waited delivery can still return at its timeout, so > inspect `delivery.status`. > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "os" > "time" > > client "github.com/axilioai/platform-go/client" > files "github.com/axilioai/platform-go/drivers/files" > option "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > c := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > stored, err := files.Upload(ctx, c, "./photo.jpg") > if err != nil { > panic(err) > } > > delivery, err := files.Push( > ctx, c, "", stored.ID, files.WithWait(time.Minute), > ) > if err != nil { > panic(err) > } > fmt.Println(delivery.Status) > } > ``` > > `files.Send` combines the upload and push. > [tab] REST API > > REST API References: > > * [**Register a file upload**](/api-reference/rest/files/register-a-file-upload): `POST /files` > * [**Mark an upload complete**](/api-reference/rest/files/mark-an-upload-complete): `POST /files/{file_id}:complete` > * [**Deliver a library file to a phone**](/api-reference/rest/phones/deliver-a-library-file-to-a-phone): `POST /phones/{phone_id}/deliveries` > > Register the upload. Use the exact byte count and media type for the file: > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > FILE_SIZE=$(wc -c < photo.jpg) > > REGISTER_RESPONSE=$(curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/files \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data "{\"filename\":\"photo.jpg\",\"mime_type\":\"image/jpeg\",\"size_bytes\":$FILE_SIZE}") > > FILE_ID=$(jq -r '.file.id' <<<"$REGISTER_RESPONSE") > UPLOAD_URL=$(jq -r '.upload_url' <<<"$REGISTER_RESPONSE") > ``` > > The command stores `file.id` and `upload_url`. Upload the raw bytes to the > presigned URL without the Axilio API-key header, then complete the file: > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request PUT \ > --url "$UPLOAD_URL" \ > --header "Content-Type: image/jpeg" \ > --header "Content-Length: $FILE_SIZE" \ > --data-binary @photo.jpg > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/files/${FILE_ID}:complete" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > Push the ready file by creating a phone delivery: > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones/ph_123/deliveries \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data "{\"file_id\":\"$FILE_ID\"}" > ``` Delivery status moves from `dispatching` to `dispatched`, then to `delivered` or `failed`. The phone may also report `bytes_transferred` while a delivery is in progress. A phone accepts up to eight deliveries in flight at once. A delivery counts while it is `dispatching` or `dispatched`, for up to 10 minutes after it was created. The ninth returns `402` with the detail `phone already has 8 pushes in flight; wait for them to finish`. Push again once a delivery reaches `delivered` or `failed`. ## Download a file Only a ready file has a `download_url`. The URL lasts 10 minutes and needs no Axilio authentication header. > [tab] Dashboard > > On [**Files**](https://app.axilio.ai/dashboard/axilio/files), choose the > download action on a ready file. Selecting a preview opens the original in a > new tab. > [tab] CLI > > ```bash theme={null} > axilio files download > axilio files download --out ./captures/photo.jpg > ``` > > The command refuses to overwrite an existing destination unless you add > `--force`. > [tab] Python SDK > > Search by filename, then stream the first ready match to a path you choose: > > ```python theme={null} > from pathlib import Path > > import httpx > > page = client.files.list(search="photo.jpg") > item = next((file for file in page.files or [] if file.download_url), None) > if item is None: > raise RuntimeError("no ready file matched photo.jpg") > > output_path = Path("photo.jpg") > with httpx.stream("GET", item.download_url, follow_redirects=True) as response: > response.raise_for_status() > with output_path.open("wb") as output: > for chunk in response.iter_bytes(): > output.write(chunk) > > print(f"saved {output_path}") > ``` > [tab] Go SDK > > Search by filename, then stream the first ready match to a path you choose: > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "io" > "log" > "net/http" > "os" > > platformgo "github.com/axilioai/platform-go" > client "github.com/axilioai/platform-go/client" > files "github.com/axilioai/platform-go/drivers/files" > option "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > c := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > page, err := files.List(ctx, c, &platformgo.FilesListRequest{ > Search: platformgo.String("photo.jpg"), > }) > if err != nil { > log.Fatal(err) > } > > var selected *platformgo.FileSummary > for _, item := range page.Files { > if item != nil && item.DownloadURL != nil { > selected = item > break > } > } > if selected == nil { > log.Fatal("no ready file matched photo.jpg") > } > > request, err := http.NewRequestWithContext( > ctx, http.MethodGet, *selected.DownloadURL, nil, > ) > if err != nil { > log.Fatal(err) > } > response, err := http.DefaultClient.Do(request) > if err != nil { > log.Fatal(err) > } > defer response.Body.Close() > if response.StatusCode < 200 || response.StatusCode >= 300 { > log.Fatalf("download failed: %s", response.Status) > } > > outputPath := "photo.jpg" > output, err := os.Create(outputPath) > if err != nil { > log.Fatal(err) > } > defer output.Close() > if _, err := io.Copy(output, response.Body); err != nil { > log.Fatal(err) > } > > fmt.Println("saved", outputPath) > } > ``` > > Re-list before retrying an expired URL. > [tab] REST API > > REST API References: > > * [**List the organization's files**](/api-reference/rest/files/list-the-orgs-files): `GET /files` > > Search by filename, then download the first ready match to a path you choose: > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > DOWNLOAD_URL=$(curl --silent --show-error --fail-with-body \ > --get \ > --url https://api.axilio.ai/api/v1/files \ > --data-urlencode "search=photo.jpg" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | > jq -r '[.files[]? | select(.status == "ready" and .download_url)][0].download_url // empty') > > if [ -z "$DOWNLOAD_URL" ]; then > echo "no ready file matched photo.jpg" >&2 > exit 1 > fi > > curl --silent --show-error --fail-with-body \ > --location \ > --url "$DOWNLOAD_URL" \ > --output photo.jpg > ``` ## Rename an uploaded file Only `source=upload` files can be renamed. A capture keeps the filename that the phone reported because it is part of the capture's provenance. Renaming is metadata-only: existing URLs still work, and earlier deliveries retain the name they used at push time. > [tab] Dashboard > > On [**Files**](https://app.axilio.ai/dashboard/axilio/files), use the rename > action beside an uploaded filename. Captured filenames have no rename > action. > [tab] Python SDK > > ```python theme={null} > renamed = client.files.update( > "", > filename="launch-photo.jpg", > ) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > client "github.com/axilioai/platform-go/client" > option "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > c := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > renamed, err := c.Files.Update(ctx, &platformgo.FileRenameRequest{ > FileID: "", > Filename: "launch-photo.jpg", > }) > if err != nil { > log.Fatal(err) > } > fmt.Println(renamed.File.Filename) > } > ``` > [tab] REST API > > REST API References: > > * [**Update a file**](/api-reference/rest/files/update-a-file): `PATCH /files/{file_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request PATCH \ > --url https://api.axilio.ai/api/v1/files/file_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"filename":"launch-photo.jpg"}' > ``` The CLI does not have a post-upload rename command. Set the stored name during upload with `axilio files upload --filename ` or use one of the rename interfaces above. ## Delete Deleting a file removes it from the library and frees its quota. Copies already on phones are not touched, whether the file was pushed to a phone or captured from one. Removing a file from a phone is a separate operation and is not available yet. > [tab] Dashboard > > Open [**Files**](https://app.axilio.ai/dashboard/axilio/files) and choose > the delete action on the file. The confirmation shows how many phones were > sent a copy; those copies stay on the phones. > [tab] CLI > > ```bash theme={null} > axilio files delete > ``` > > Interactive table mode asks for confirmation. Non-interactive, JSON, and > quiet execution require `--yes`. > [tab] Python SDK > > ```python theme={null} > client.files.delete("") > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "log" > "os" > > client "github.com/axilioai/platform-go/client" > files "github.com/axilioai/platform-go/drivers/files" > option "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > c := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > if err := files.Delete(ctx, c, ""); err != nil { > log.Fatal(err) > } > } > ``` > [tab] REST API > > REST API References: > > * [**Delete a file**](/api-reference/rest/files/delete-a-file): `DELETE /files/{file_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request DELETE \ > --url https://api.axilio.ai/api/v1/files/file_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > The response's `phones_pending_removal` field is deprecated and always `0`. > [!NOTE] > > Deleting a captured file removes the library copy only. The media stays on > the phone it was captured from, and on any phone it was later pushed to. # Files Source: https://docs.axilio.ai/files/overview Understand the organization file library, upload and capture provenance, phone deliveries, filters, and storage limits. Files is your organization's shared media library. A file can enter the library because you uploaded it or because a phone session captured it. Every ready file has one ID and can be downloaded locally or delivered to a phone. ## The file model | Concept | Customer-facing contract | | - | - | | **Library** | A flat, organization-scoped collection. Files persist until you delete them. | | **Upload** | An action that stores a local image or video. The resulting file has `source=upload`. | | **Capture** | A file collected from a session. It has `source=capture`, `surface=phone`, and a `session_id`. | | **Delivery** | One attempt to push a library file to a phone. It has its own ID, status, progress, and error fields. | > [!NOTE] > > Upload and capture describe provenance. They are not separate libraries. > Uploaded and captured files share the same ID space and delivery flow. ## Limits and lifecycle * The library accepts supported image and video formats. * A library file can be up to 100 MiB, which is also the phone-delivery ceiling. Files stored under the earlier 1 GiB library limit remain listed, but a push still refuses anything over 100 MiB. * An organization's library holds up to 10,000 files and 50 GiB. Past either limit, registering an upload returns `402` with the detail `file library holds the maximum 10000 files; delete files to upload more` or `org file storage quota exceeded; delete files to free space`. Delete files to make room. * Every library listing reports the organization's current file-count and byte usage against its quota. Treat those returned limits as authoritative. * An upload URL lasts 15 minutes; the response gives the time left in `upload_expires_in_seconds`. If you don't finish the upload in that time, it stops counting toward both the file and the byte limit. * An upload becomes usable after its bytes are verified and its status reaches `ready`. * A capture has a more detailed `capture_state`. It can remain visible even when the phone skips it or the transfer fails. * Download URLs last 10 minutes. List the file again to obtain a new URL after one expires. Search and filter the library instead of navigating folders. You can filter by source, capture surface, session, MIME type, size, and creation time. You can sort by creation time, filename, size, or source. ## Open the library > [tab] Dashboard > > Open [**Files**](https://app.axilio.ai/dashboard/axilio/files) for your > organization. The organization-specific route is > `/dashboard//files`. > > The page shows uploads and captures together. Filter by source, search by > filename, switch between grid and list views, and inspect live quota usage. > [tab] CLI > > ```bash theme={null} > axilio files list > axilio files list --source capture --surface phone > ``` > > The table includes source, state, session, and quota usage. Use `-o json` > for the complete response. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > page = client.files.list(limit=50) > > for file in page.files or []: > print(file.id, file.source, file.filename, file.status) > > print(page.usage.file_count, page.usage.file_limit) > ``` > [tab] Go SDK > > ```go theme={null} > page, err := files.List(ctx, c, &platformgo.FilesListRequest{}) > if err != nil { > return err > } > > for _, file := range page.Files { > fmt.Println(file.ID, file.Source, file.Filename, file.Status) > } > ``` > > This example uses `github.com/axilioai/platform-go/drivers/files` for > `files.List` and `github.com/axilioai/platform-go` for the request type. > [tab] REST API > > REST API References: > > * [**List the organization's files**](/api-reference/rest/files/list-the-orgs-files): `GET /files` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/files?limit=50" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > `GET /api/v1/files` returns `files`, `total`, and `usage`. > [!NOTE] > > Use the `axilio files` command group in new scripts. Older directional CLI > groups remain hidden compatibility aliases, not the current interface. ## Next steps > [card] [Move files](/files/move-files) > > Upload, download, push, rename, and delete files. > [card] [Session captures](/files/session-captures) > > Find files that a session captured from its phone. # Session captures Source: https://docs.axilio.ai/files/session-captures Find media captured from a phone session, interpret capture states, and download or reuse ready files. When capture is enabled, Axilio collects supported media that a session produces on its phone and stores it in the organization's Files library. Capture is enabled by default for sessions and runs. A session, an agent, or an ad hoc run can turn it off explicitly. Each captured file has: * `source=capture` * `surface=phone` * the `session_id` that produced it * a `capture_state` and optional `capture_error` Rows appear as soon as the phone detects media. You can therefore observe an in-progress capture instead of polling an empty list until the transfer ends. ## Capture states | State | Meaning | | - | - | | `detected` | The phone reported the file. Transfer has not started yet. | | `uploading` | Bytes are moving into the organization library. | | `ready` | The bytes are verified and the file can be downloaded or pushed. | | `skipped_size` | The file exceeded the capture size policy. | | `skipped_quota` | The organization did not have enough remaining file quota. | | `skipped_type` | The media type is not supported. | | `dropped_teardown` | The session ended before Axilio could finish the capture. | | `failed` | Capture failed. Read `capture_error` for the reason. | Use `capture_state` for captured files. The general `status` field describes only whether bytes are still uploading or ready in storage. ## Find a session's captures > [tab] Dashboard > > Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions), select > the active session, open its **Files** tool, and select **Downloads** to watch > files from that session arrive. You can also open > [**Files**](https://app.axilio.ai/dashboard/axilio/files), filter to captures, > and follow the session link on a file. > [tab] CLI > > List captures for one session: > > ```bash theme={null} > axilio sessions files > ``` > > Or apply the same session filter to the organization library: > > ```bash theme={null} > axilio files list --source capture --surface phone --session > ``` > > Download a ready capture with `axilio files download `. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > page = client.files.phones_session_files( > "", > limit=50, > ) > > for file in page.files or []: > print(file.id, file.filename, file.capture_state, file.capture_error) > ``` > > Re-run the listing to refresh in-progress state and expired download URLs. > [tab] Go SDK > > ```go theme={null} > limit := int64(50) > page, err := c.Files.PhonesSessionFiles( > ctx, > &platformgo.PhonesSessionFilesRequest{ > SessionID: "", > Limit: &limit, > }, > ) > if err != nil { > return err > } > > for _, file := range page.Files { > state := "" > if file.CaptureState != nil { > state = string(*file.CaptureState) > } > fmt.Println(file.ID, file.Filename, state) > } > ``` > [tab] REST API > > REST API References: > > * [**List a session's captured files**](/api-reference/rest/files/list-a-sessions-captured-files): `GET /phones/sessions/{session_id}/files` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/phones/sessions/sess_123/files?limit=50" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > `GET /api/v1/phones/sessions/{session_id}/files` returns captured files > newest first. The endpoint also supports filename, MIME type, size, time, > limit, and offset filters. ## Use a ready capture A ready capture behaves like any other library file: * Download it locally from its short-lived `download_url`. * Push it to any phone your organization holds by using its file ID. * Delete it from the library to free quota. Captured filenames cannot be renamed. Deleting the library entry does not delete the original media from the source phone. > [card] [Move files](/files/move-files) > > Download, push, or delete a ready capture. > [card] [Files overview](/files/overview) > > Review provenance, filters, quotas, and delivery concepts. # The Mobile Cloud for Agents Source: https://docs.axilio.ai/index Run agents on real Android phones and control them through the Dashboard, CLI, Python, Go, or REST API. Axilio gives agents **real, dedicated Android phones in the cloud**. Pick up a phone, read its screen, then tap, type, and swipe just as a person would. Use Axilio when a mobile app has no suitable public API. Your agent can work through the same screen and carrier network a person uses, then turn a tested interaction into a repeatable Python or Go program. ## Give your AI a phone Paste this prompt into an agent with terminal access to the environment where Axilio will run, such as Claude Code, Codex, or Cursor. Tell it which app you want to use and the first result you want. You'll get help with setup, sign-in, and app preparation, then complete an approved task on your dedicated phone and receive a reusable Python or Go script. A cloud agent runs commands in its own environment. Installing the CLI on your computer does not give a separate cloud chat access to it. If your agent cannot run terminal commands, use a terminal-capable agent to continue. > [card] [CLI](/cli) > > Allocate and drive phones from a terminal or coding agent. > [card] [Dashboard](https://app.axilio.ai/dashboard/axilio/sessions) > > Manage phones, sessions, agents, runs, files, usage, and billing. > [card] [SDK quickstart](/quickstart) > > Choose Python or Go and inspect your first phone session. > [card] [API Reference](/api-reference/overview) > > Build directly on the REST API or Device Control Protocol. ## What you can do > [card] [Phones and Sessions](/devices/overview) > > Choose a dedicated phone, allocate a session, and release it when you finish. > [card] [Agents and Runs](/agents/overview) > > Hand a phone a task, save it as an agent, and run it on demand or on a schedule. > [card] [Files](/files/overview) > > Upload media, deliver it to phones, and collect files from sessions. > [card] [Mobile driver](/driver/overview) > > Observe a screen, find elements, and tap, type, swipe, or take screenshots. > [card] [Telemetry](/telemetry/overview) > > Understand session operations, timing, output, errors, and cost context. > [card] [Observability](/observability/overview) > > Watch an active phone screen and replay available recordings. > [card] [Usage and Billing](/billing/overview) > > Understand usage, balances, invoices, dedicated pricing, and account settings. ## Choose an interface [Web app Dashboard](https://app.axilio.ai/dashboard/axilio/sessions) Terminal CLI [Open](/cli) [View the Axilio CLI on GitHub](https://github.com/axilioai/cli) [Python Python SDK](/quickstart#python) [View the Axilio Python SDK on GitHub](https://github.com/axilioai/platform-python) [Go Go SDK](/quickstart#go) [View the Axilio Go SDK on GitHub](https://github.com/axilioai/platform-go) # What is Axilio? Source: https://docs.axilio.ai/introduction Axilio is the mobile cloud for agents: real Android phones, hosted agents, files, and Telemetry exposed through customer-facing APIs and tools. Axilio is a cloud of **real Android phones** that software can control. Your agent can see a phone's screen and tap, type, swipe, or capture a screenshot in the same environment a mobile user experiences. ## Why a real phone? Many mobile apps do not expose a public API for the task you need. Axilio lets your agent use the app through its screen on physical hardware and a carrier network. > [!NOTE] > > Axilio phones are physical Android devices, not emulators. A phone can be in > only one active session at a time. ## The core concepts | Concept | What it means | | - | - | | **Phone** | The physical mobile device your organization can allocate. | | **Session** | A temporary reservation of one phone. Release the session when the work is done. | | **Driver** | The control interface inside a session. It observes the screen and performs input actions. | | **Agent** | A task for a phone saved in Axilio, as plain-English instructions for a model or as Python code, so it can run again. | | **Run** | One execution of a task or an agent on a phone. Its run ID is also used as the session ID after allocation. | | **File** | An item in your organization's library. It either entered by upload or was captured from a session. | | **Telemetry** | The session-scoped execution record that explains operations, timing, output, and errors while a session runs and afterward. | ## Next steps > [card] [CLI](/cli) > > Work with phones and platform resources from your terminal. > [card] [SDK quickstart](/quickstart) > > Install the Python SDK and inspect your first phone. > [card] [Authentication](/authentication) > > Create an API key and configure each interface safely. > [card] [Files](/files/overview) > > Move media into, out of, and between phone sessions. > [card] [Telemetry](/telemetry/overview) > > Understand and inspect a session's execution record. # Live view Source: https://docs.axilio.ai/observability/live-view Watch a phone in real time, share a session-scoped viewer, or embed the interactive screen in your app. Live view shows a phone's screen while its session is active. The Dashboard includes the live phone in its session viewer. Direct allocations through the CLI, SDKs, and REST API also return a hosted `live_view_url` unless you disable live view. The default hosted URL is interactive. Pointer and keyboard input reach the phone. Use a [recording](/observability/recordings) after the session ends. ## Open an active phone > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Select an active session or click **Allocate A Phone** to start one. > 3. Use the phone on the left side of the **SESSION VIEWER**. The Dashboard > connects with your signed-in organization membership and reconnects the > stream when needed. > > The Dashboard does not expose the hosted viewer URL or its embed options. > Use an SDK or the REST API when another application needs the URL. > [tab] CLI > > Start a session and open the **Live view** URL printed in the result: > > ```bash theme={null} > axilio sessions start --phone-id ph_123 > ``` > > Use JSON output when another shell command needs the URL: > > ```bash theme={null} > axilio sessions start --phone-id ph_123 -o json | jq -r '.live_view_url' > ``` > > The CLI does not have a command to mint another viewer URL for an existing > session. Retain the allocation result or use an SDK or REST for that step. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > allocation = client.phones.allocate( > phone_type="android", > phone_id="ph_123", > ) > > try: > if not allocation.live_view_url: > raise RuntimeError("live view is disabled") > > print(allocation.live_view_url) > input("Press Enter to release the phone...") > finally: > client.phones.deallocate(allocation.phone_id) > ``` > > Keep the program running while you use the URL. Press Enter to release the > phone and end the session. > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > }) > if err != nil { > log.Fatal(err) > } > defer api.Phones.Deallocate(ctx, &platformgo.PhonesDeallocateRequest{ > PhoneID: allocation.PhoneID, > }) > > if allocation.LiveViewURL == nil { > log.Print("live view is disabled") > return > } > > fmt.Println(*allocation.LiveViewURL) > fmt.Println("Press Enter to release the phone...") > fmt.Scanln() > } > ``` > > Keep the program running while you use the URL. Press Enter to release the > phone and end the session. > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > * [**Release a phone**](/api-reference/rest/phones/release-a-phone): `POST /phones/{phone_id}:deallocate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > ALLOCATION=$(curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"phone_type":"android","phone_id":"ph_123"}') > > PHONE_ID=$(printf '%s\n' "$ALLOCATION" | jq -r '.phone_id') > printf '%s\n' "$ALLOCATION" | jq -r '.live_view_url' > read -r -p "Press Enter to release the phone..." _ > > curl --silent --show-error --fail-with-body \ > --request POST \ > --url "https://api.axilio.ai/api/v1/phones/$PHONE_ID:deallocate" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > Keep the command running while you use the URL. Press Enter to release the > phone and end the session. ## Mint a fresh viewer URL If you no longer have the allocation response, mint a fresh URL for an active session. The Dashboard manages its connection automatically. The CLI does not expose this operation. > [tab] Python SDK > > ```python theme={null} > from axilio.platform import Client > > client = Client() > grant = client.phones.session_live_view_token("sess_123") > print(grant.live_view_url) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > grant, err := api.Phones.SessionLiveViewToken( > context.Background(), > &platformgo.PhonesSessionLiveViewTokenRequest{SessionID: "sess_123"}, > ) > if err != nil { > log.Fatal(err) > } > > fmt.Println(grant.LiveViewURL) > } > ``` > [tab] REST API > > REST API References: > > * [**Mint a live-view token**](/api-reference/rest/phones/mint-a-live-view-token): `POST /phones/sessions/{session_id}:createLiveViewToken` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones/sessions/sess_123:createLiveViewToken \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > | jq -r '.live_view_url' > ``` This operation preserves the session's allocation-time `view_only` and `disabled` settings. A caller with the viewer role always gets a watch-only URL; only members and admins get one that can drive the phone, and only when the session wasn't allocated with `view_only`. It refuses inactive sessions, sessions outside your organization, and sessions whose live view was disabled. Its response is a bearer viewer URL even when the original allocation used organization authentication. Minting a new URL does not revoke an older one. ## Choose authentication and input behavior The Dashboard and CLI use the default interactive viewer behavior and do not expose these allocation options. Configure them with an SDK or REST: > [tab] Python SDK > > ```python theme={null} > from axilio import PhoneLiveViewOptions > from axilio.platform import Client > > client = Client() > allocation = client.phones.allocate( > phone_type="android", > phone_id="ph_123", > live_view=PhoneLiveViewOptions( > auth="token", > view_only=True, > ), > ) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > LiveView: &platformgo.PhoneLiveViewOptions{ > Auth: platformgo.PhoneLiveViewOptionsAuthToken.Ptr(), > ViewOnly: platformgo.Bool(true), > }, > }) > if err != nil { > log.Fatal(err) > } > > log.Printf("started view-only session %s", allocation.SessionID) > } > ``` > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "phone_type": "android", > "phone_id": "ph_123", > "live_view": {"auth": "token", "view_only": true} > }' > ``` | Option | Effect | | - | - | | `auth="token"` | Default. The allocation URL contains a bearer credential and opens without an Axilio sign-in. | | `auth="org"` | The allocation URL is tokenless. You must sign in and belong to the session's organization. | | `view_only=True` | Watch-only. The server blocks input. | | `disabled=True` | No hosted live view exists, and allocation omits `live_view_url`. | Use organization authentication for links shared inside your team. Third-party iframe cookie policies can prevent signed-in organization mode from working inside your product, so use token mode for end-user embeds. ## Embed the viewer The hosted page is chrome-less and iframe-embeddable: ```html theme={null} ``` The stream starts muted so it can render under browser autoplay policies. If your app sets a `Content-Security-Policy`, allow the hosted page and its realtime connection: ```text theme={null} frame-src https://app.axilio.ai; connect-src https://connect.axilio.ai wss://connect.axilio.ai; ``` ### Keyboard focus Browsers deliver keystrokes to an iframe only while it has focus. Safari requires a user action before it focuses a cross-origin iframe. Focus it from a click handler: ```js theme={null} iframe.addEventListener("click", () => iframe.focus()) ``` Pointer input does not require this step. ### Appearance The default is the bare phone screen on a transparent background. Query parameters customize the surround: | Parameter | Values | Default | | - | - | - | | `bg` | `transparent`, `dark`, `light`, or a 3- or 6-digit hex color | `transparent` | | `frame` | `device` to add a phone frame | Bare screen | ```html theme={null} ``` ## Viewer limit A session accepts up to four connected viewers at once, counting the Dashboard, hosted links, and embedded viewers together. A viewer that is still connecting does not keep its place: if one of the four has not finished connecting when a new viewer arrives, Axilio drops it to make room. When all four are connected, Axilio refuses the next viewer. The hosted viewer shows "This session is at its live-viewer limit." and the Dashboard shows **Session full**. Neither retries on its own; reconnect after another viewer closes. ## Protect capability URLs Anyone with a bearer `live_view_url` can watch the phone and—unless the session is view-only—interact with it until the session ends. This includes the allocation URL in token mode and every URL returned by the token operation. * Do not log the URL or send it to analytics. * Prefer `view_only=True` for audiences that should not control the phone. * Prefer `auth="org"` for internal sharing that should require membership. * If you expose a token-mode URL, end the session. The URL stops working when the session ends. Release directly allocated phones when you finish. Closing the viewer does not release the phone. ## Next steps > [card] [Live Telemetry](/telemetry/live) > > Follow what the session is doing while you watch. > [card] [Recordings](/observability/recordings) > > Replay a session after it ends. > [card] [Retrieve Telemetry](/telemetry/retrieve) > > Inspect its retained timeline and logs. # Observability Source: https://docs.axilio.ai/observability/overview Watch a phone session live and replay its screen recording after it ends. Observability covers the phone's visual state during and after a session. Use Live view to watch an active phone. Use Recordings to replay the screen after a recorded session finishes processing. > [card] [Live view](/observability/live-view) > > Watch or embed a phone's interactive screen. > [card] [Recordings](/observability/recordings) > > Replay a recorded session after processing completes. ## Choose the right surface | You want to | Use | | - | - | | Watch or embed the phone screen while a session is active | [Live view](/observability/live-view) | | Replay the screen after the session | [Recordings](/observability/recordings) | | Understand operations, timing, output, errors, or per-call cost | [Telemetry](/telemetry/overview) | Live view, recording, and Telemetry use different credentials and controls. A Telemetry URL is read-only. A live-view URL can carry broader phone privileges unless the session is view-only. Neither live URL is a recording or archive credential. ## Follow a visual session 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). 2. Select an active session to watch its screen in **Live view**. 3. After the session ends, wait for its recording status to become `ready`. 4. Replay the recording and, when useful, compare it with the session's [Telemetry dashboard](/telemetry/understand-a-trace). Recording playback can synchronize its playhead with Telemetry time so you can compare screen changes with the operations that caused them. ## Control recording separately Telemetry is enabled by default and recording is off by default. They are independent: * `recording: true` writes a screen recording of the session. Without it, no recording is written. * `telemetry: false` prevents both live and retained Telemetry without affecting recording. See [Telemetry data controls](/telemetry/data-controls) for retention, sensitive-data, and Telemetry opt-out guidance. # Recordings Source: https://docs.axilio.ai/observability/recordings Configure screen recording, understand its states, and replay or download a completed session. Screen recording is off by default. Turn it on for a session, an agent, or an ad hoc run, and Axilio records the phone's screen and makes the MP4 available after the session ends and its upload finishes. Recordings are keyed by `session_id`, so the same lookup works for runs and directly allocated sessions. A run's session ID is its run ID. ## Configure recording Recording is an allocation-time setting. Changing an agent affects its future runs, not sessions that already started. > [tab] Dashboard > > Record one directly allocated session: > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Click **Allocate A Phone**. > 3. Expand **advanced configuration**. > 4. Turn **recording** on. > 5. Click **Allocate**. > > Record an ad hoc run: on > [**Run a task**](https://app.axilio.ai/dashboard/axilio/agents/run), turn > on **Record the screen** before you start the run. > > A run that was not recorded shows **Recording was off** in the session > viewer. For a saved agent's run, members also see **Record future runs**, > which turns recording on for the agent. > [tab] CLI > > Record an agent's runs when you create it: > > ```bash theme={null} > axilio agents create checkout-flow --code checkout.py --recording=true > ``` > > Confirm its current setting with `axilio agents get `. The CLI > does not expose a recording flag on `sessions start` or an update command > for an existing agent. > [tab] Python SDK > > Record one direct allocation: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > allocation = client.phones.allocate( > phone_type="android", > phone_id="ph_123", > recording=True, > ) > ``` > > To record an agent's future runs, set `recording_enabled=True` with > `client.agents.update()` (Python SDK 0.23.0 or later). > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > recordingOn := true > > allocation, err := api.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > Recording: &recordingOn, > }) > if err != nil { > log.Fatal(err) > } > defer func() { > if _, err := api.Phones.Deallocate( > context.Background(), > &platformgo.PhonesDeallocateRequest{PhoneID: allocation.PhoneID}, > ); err != nil { > log.Printf("release phone: %v", err) > } > }() > } > ``` > > The allocation setting applies only to `allocation.SessionID`. To record > an agent's future runs, set `RecordingEnabled` with `api.Agents.Update` > (Go SDK v0.16.0 or later). > [tab] REST API > > REST API References: > > * [**Allocate a phone**](/api-reference/rest/phones/allocate-a-phone): `POST /phones:allocate` > * [**Update an agent**](/api-reference/rest/agents/update-an-agent): `PATCH /agents/{agent_id}` > * [**Start a run**](/api-reference/rest/runs/start-a-run): `POST /runs` > > Record one direct allocation: > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/phones:allocate \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{ > "phone_type": "android", > "phone_id": "ph_123", > "recording": true > }' > ``` > > Record future runs of an existing agent: > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request PATCH \ > --url https://api.axilio.ai/api/v1/agents/agent_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"recording_enabled": true}' > ``` > > Record one ad hoc run by sending `"recording_enabled": true` on > `POST /runs`. When recording is off, Axilio does not write a recording for that session. Your organization's data-retention policy can also prevent recordings from being created or make older recordings unavailable. ## Replay or download a recording > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Change the status filter if the completed session is not visible. > 3. Select the session. > 4. Play the recording on the left side of the **SESSION VIEWER**. > > The **Timeline** and **Console** stay synchronized with playback. Select a > timeline bar or console entry to seek to that point. The viewer shows a > processing, expired, or recording-off state when no playable URL exists. > [tab] CLI > > Look up the recording's status and, when it is ready, a time-limited URL: > > ```bash theme={null} > axilio sessions recording sess_123 > axilio sessions recording sess_123 -o json > ``` > > The lookup exits 0 for `ready`, `pending`, `expired`, and `off`, so a > script can branch on the `status` field. > > Download the MP4. `--wait` polls while the recording is still processing > (default timeout 10 minutes, exit status 5 when it elapses), and `--force` > overwrites an existing file: > > ```bash theme={null} > axilio sessions recording sess_123 --wait --out session.mp4 > ``` > > The file is written atomically, so an interrupted download never leaves a > partial MP4 behind. A pending recording is refused without `--wait`; an > expired recording cannot be downloaded and exits with not-found status 4. > Run the lookup again instead of persisting a URL: returned URLs expire. > [tab] Python SDK > > ```python theme={null} > from pathlib import Path > > import httpx > > from axilio.platform import Client > > client = Client() > recording = client.phones.session_recording("sess_123") > > if recording.status != "ready" or not recording.url: > raise RuntimeError(f"recording is {recording.status}") > > with httpx.stream("GET", recording.url, follow_redirects=True) as response: > response.raise_for_status() > with Path("session.mp4").open("wb") as output: > for chunk in response.iter_bytes(): > output.write(chunk) > ``` > [tab] Go SDK > > ```go theme={null} > package main > > import ( > "context" > "errors" > "io" > "log" > "net/http" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > recording, err := api.Phones.SessionRecording( > ctx, > &platformgo.PhonesSessionRecordingRequest{SessionID: "sess_123"}, > ) > if err != nil { > log.Fatal(err) > } > if recording.Status != platformgo.PhoneSessionRecordingResponseStatusReady || > recording.URL == nil { > log.Fatal(errors.New("recording is not ready")) > } > > response, err := http.Get(*recording.URL) > if err != nil { > log.Fatal(err) > } > defer response.Body.Close() > if response.StatusCode < 200 || response.StatusCode >= 300 { > log.Fatalf("download recording: %s", response.Status) > } > > output, err := os.Create("session.mp4") > if err != nil { > log.Fatal(err) > } > defer output.Close() > > if _, err := io.Copy(output, response.Body); err != nil { > log.Fatal(err) > } > } > ``` > [tab] REST API > > REST API References: > > * [**Get a session recording**](/api-reference/rest/phones/get-a-session-recording): `GET /phones/sessions/{session_id}/recording` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > RECORDING=$(curl --silent --show-error --fail-with-body \ > --request GET \ > --url https://api.axilio.ai/api/v1/phones/sessions/sess_123/recording \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY") > > STATUS=$(printf '%s' "$RECORDING" | jq -r '.status') > if [ "$STATUS" != "ready" ]; then > printf 'recording is %s\n' "$STATUS" >&2 > exit 1 > fi > > RECORDING_URL=$(printf '%s' "$RECORDING" | jq -r '.url') > curl --silent --show-error --fail --location \ > "$RECORDING_URL" \ > --output session.mp4 > ``` ## Recording states | Status | Meaning | | - | - | | `ready` | `url` contains a playback link that stays valid for one hour. | | `pending` | The session recorded, but the recording is not available yet. The session may still be active, or its upload may still be processing. | | `expired` | The recording is outside the organization's retention window. | | `off` | The session was not recorded. No recording will appear. | Only `ready` includes a URL. For `pending`, fetch the lookup again while the upload processes, or let `axilio sessions recording --wait` poll for you. ## Next steps > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Inspect the execution timeline beside the video. > [card] [Live view](/observability/live-view) > > Watch a session as it happens. # Members and invitations Source: https://docs.axilio.ai/org/members Manage organization membership in the dashboard and read member or invitation summaries through the public API. Member and invitation changes are dashboard-only. The public API exposes read-only summaries so an integration can audit access without changing the organization that issued its key. ## Manage access 1. Open [**Settings → Members**](https://app.axilio.ai/dashboard/axilio/settings/members) after choosing your organization. 2. Click **Invite member** to send an invitation. 3. Use the member row to change a role or remove access. 4. Use the invitations list to review or cancel outstanding invitations. The CLI, Python SDK, Go SDK, and public REST API cannot invite, change, or remove members. The CLI also has no commands for listing members or invitations. Use the dashboard or the read-only public API for those operations. ## List members > [tab] Dashboard > > Open [**Settings → Members**](https://app.axilio.ai/dashboard/axilio/settings/members) > after selecting the organization. Accepted members appear as active rows > with their name, email address, role, and join date. > > The dashboard page is available to organization admins. > [tab] Python SDK > > ```python theme={null} > members = client.raw.organization.list_members() > > for member in members.members or []: > print(member.email, member.role, member.joined_at) > ``` > [tab] Go SDK > > ```go theme={null} > members, err := api.Organization.ListMembers(ctx) > if err != nil { > log.Fatal(err) > } > for _, member := range members.Members { > fmt.Println(member.Email, member.Role, member.JoinedAt) > } > ``` > [tab] REST API > > REST API References: > > * [**List the organization's members**](/api-reference/rest/organization/list-the-organizations-members): `GET /organization/members` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/organization/members \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## List outstanding invitations > [tab] Dashboard > > Open [**Settings → Members**](https://app.axilio.ai/dashboard/axilio/settings/members) > after selecting the organization. Pending invitations appear in the same > table as accepted members, with their requested role and invitation date. > > The dashboard page is available to organization admins. > [tab] Python SDK > > ```python theme={null} > invitations = client.raw.organization.list_invitations() > > for invitation in invitations.invitations or []: > print(invitation.email, invitation.role, invitation.invited_at) > ``` > [tab] Go SDK > > ```go theme={null} > invitations, err := api.Organization.ListInvitations(ctx) > if err != nil { > log.Fatal(err) > } > ``` > [tab] REST API > > REST API References: > > * [**List pending invitations**](/api-reference/rest/organization/list-pending-invitations): `GET /organization/invitations` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/organization/invitations \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` The public members endpoint requires the viewer role or higher. The public invitations endpoint requires an organization admin. The dashboard page is admin-only for both lists. # Organizations Source: https://docs.axilio.ai/org/overview Understand organization scope, switch organizations, and read organization details across interfaces. An **organization** owns its phones, sessions, agents, runs, files, API keys, usage, and billing. An API key selects one organization for Python SDK, Go SDK, and REST API requests. OAuth CLI sessions can switch among the organizations your user belongs to. ## Select an organization > [tab] Dashboard > > Use the organization switcher in the dashboard sidebar. Dashboard URLs > include the selected organization's slug. > > Open [**Settings → General**](https://app.axilio.ai/dashboard/axilio/settings/organization) > to inspect the selected organization. > [tab] CLI > > ```bash theme={null} > axilio orgs list > axilio orgs use example-org > > # Override the selection for one command. > axilio --org another-org agents list > ``` > > Organization switching requires an OAuth login. API-key logins stay bound > to the key's organization. The Python SDK, Go SDK, and REST API do not provide a runtime organization selector when you authenticate with an API key. The credential determines the organization. To access another organization, use a key issued by that organization and create another SDK client or change the REST credential. > [!NOTE] > > `X-Axilio-Org` selects an organization only for CLI OAuth requests. It is > ignored when you authenticate with an API key, even if an SDK lets you add > custom request headers. ## Read the organization descriptor Available fields differ by interface. The SDKs and REST API return the full public organization descriptor. > [tab] Dashboard > > Open [**Settings → General**](https://app.axilio.ai/dashboard/axilio/settings/organization) > after selecting the organization. > > The admin-only page shows the organization's name, slug, ID, creation date, > and additional account information. It is a dashboard summary, not the > exact public API descriptor. For example, it does not show `plan_name`. > [tab] CLI > > ```bash theme={null} > axilio orgs list > ``` > > This OAuth-only command returns each accessible organization's ID, slug, > and name. It is a partial summary, not the full public descriptor. It does > not return `plan_name` or `created_at`. > [tab] Python SDK > > ```python theme={null} > organization = client.raw.organization.get() > > print(organization.id, organization.slug, organization.name) > print(organization.plan_name) > ``` > [tab] Go SDK > > ```go theme={null} > organization, err := api.Organization.Get(ctx) > if err != nil { > log.Fatal(err) > } > fmt.Println(organization.ID, organization.Slug, organization.Name) > ``` > [tab] REST API > > REST API References: > > * [**Get the caller's organization**](/api-reference/rest/organization/get-the-callers-organization): `GET /organization` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/organization \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` Renaming an organization and changing its members or invitations are dashboard-only actions. The public API can read member and invitation summaries but cannot mutate them. ## Next steps > [card] [Members](/org/members) > > Manage members in the dashboard or read summaries through the API. > [card] [API keys](/platform/api-keys) > > Create, list, replace, and delete organization credentials. > [card] [Billing](/billing/overview) > > Read the organization's usage, balance, invoices, and dedicated rentals. # API keys Source: https://docs.axilio.ai/platform/api-keys Create, list, replace, and revoke organization API keys. API keys authenticate the CLI, SDKs, and REST API. Each key belongs to one organization and starts with `axl_`. Listing, creating, and deleting API keys requires an organization admin. ## Create a key > [tab] Dashboard > > 1. Open [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys) after choosing your organization. > 2. Click **Create Key**. > 3. Enter a name and create the key. > 4. Copy the secret before closing the dialog. > [tab] CLI > > ```bash theme={null} > axilio api-keys create ci-bot > ``` > > Add `-o json` for structured output. > [tab] Python SDK > > ```python theme={null} > key = client.api_keys.create(name="ci-bot") > print(key.id, key.key_value) > ``` > [tab] Go SDK > > ```go theme={null} > key, err := api.APIKeys.Create(ctx, &platformgo.APIKeyCreateRequest{ > Name: "ci-bot", > }) > if err != nil { > log.Fatal(err) > } > fmt.Println(key.ID, key.KeyValue) > ``` > [tab] REST API > > REST API References: > > * [**Create an API key**](/api-reference/rest/api-keys/create-an-api-key): `POST /api-keys` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/api-keys \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"name":"ci-bot"}' > ``` > [!WARNING] > > Axilio returns the full `key_value` once, when you create the key. Store it in > a secret manager. List operations return only a masked preview. ## List keys > [tab] Dashboard > > Open [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys) to search > by name or preview and inspect creator, last-used time, and creation time. > [tab] CLI > > ```bash theme={null} > axilio api-keys list > ``` > [tab] Python SDK > > ```python theme={null} > keys = client.api_keys.list(limit=50) > > for key in keys.api_keys or []: > print(key.id, key.name, key.key_preview, key.last_used_at) > ``` > [tab] Go SDK > > ```go theme={null} > keys, err := api.APIKeys.List(ctx, &platformgo.APIKeysListRequest{ > Limit: platformgo.Int64(50), > }) > ``` > [tab] REST API > > REST API References: > > * [**List API keys**](/api-reference/rest/api-keys/list-api-keys): `GET /api-keys` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/api-keys?limit=50" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Replace or delete a key There is no regenerate operation. Create the replacement, update and verify every consumer, then delete the old key. > [tab] Dashboard > > 1. Open [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys), then > create and save the replacement key. > 2. Update every consumer and verify it can authenticate. > 3. Select the old key, click **Delete**, and confirm. > [tab] CLI > > ```bash theme={null} > axilio api-keys create replacement > # Save the secret and move consumers before continuing. > axilio api-keys delete key_123 --yes > ``` > [tab] Python SDK > > ```python theme={null} > replacement = client.api_keys.create(name="replacement") > print(replacement.key_value) > > # Move and verify consumers before deleting the old key. > client.api_keys.delete("key_123") > ``` > [tab] Go SDK > > ```go theme={null} > replacement, err := api.APIKeys.Create(ctx, &platformgo.APIKeyCreateRequest{ > Name: "replacement", > }) > if err != nil { > log.Fatal(err) > } > > _, err = api.APIKeys.Delete(ctx, &platformgo.APIKeysDeleteRequest{ > KeyID: "key_123", > }) > ``` > [tab] REST API > > REST API References: > > * [**Create an API key**](/api-reference/rest/api-keys/create-an-api-key): `POST /api-keys` > * [**Delete an API key**](/api-reference/rest/api-keys/delete-an-api-key): `DELETE /api-keys/{key_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request POST \ > --url https://api.axilio.ai/api/v1/api-keys \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > --header "Content-Type: application/json" \ > --data '{"name":"replacement"}' > > # Save the new secret, migrate every consumer, and verify authentication. > > curl --silent --show-error --fail-with-body \ > --request DELETE \ > --url https://api.axilio.ai/api/v1/api-keys/key_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` Deleting a key takes effect for subsequent requests. Do not delete the credential currently running an unattended migration until the replacement is confirmed. ## Next steps > [card] [Authentication](/authentication) > > Configure the CLI and SDKs without committing secrets. > [card] [Organizations](/org/overview) > > Understand organization binding and CLI selection. # SDK quickstart Source: https://docs.axilio.ai/quickstart Use the Python or Go SDK to authenticate, allocate a real Android phone, and inspect its screen. Use the Python or Go SDK to allocate a real Android phone, inspect its screen, and save a screenshot. Choose a language once and every code group on this page will follow your selection. ## Before you start * **An Axilio account:** you need an invitation to sign up. Request access at [axilio.ai](https://axilio.ai) or email [contact@axilio.ai](mailto:contact@axilio.ai). * **An Axilio API key:** open [**API Keys**](https://app.axilio.ai/dashboard/axilio/api-keys) for your organization. See [Authentication](/authentication) for details. * **A dedicated phone ID:** open [**Phones**](https://app.axilio.ai/dashboard/axilio/phones) and choose an idle phone, or run `axilio phones list`. Replace `ph_123` below with its ID. > [tab] Python SDK > > Install **Python 3.10 or newer**. > [tab] Go SDK > > Install **Go 1.23 or newer**. ## 1. Install the SDK > [tab] Python SDK > > ```bash theme={null} > pip install axilio > ``` > > On Windows, run `py -m pip install axilio` if `pip` is not on your `PATH`. > [tab] Go SDK > > ```bash theme={null} > mkdir axilio-quickstart > cd axilio-quickstart > go mod init example.com/axilio-quickstart > go get github.com/axilioai/platform-go@latest > ``` ## 2. Set your API key ```bash macOS and Linux theme={null} export AXILIO_API_KEY=axl_your_key_here ``` ```powershell Windows (PowerShell) theme={null} $env:AXILIO_API_KEY = "axl_your_key_here" ``` The variable lasts for the current terminal. Keep the key out of source control. Use your platform's secret manager in CI and deployed services. ## 3. Inspect a phone > [tab] Python SDK > > `Client` authenticates requests and exposes the platform's resource groups. > `client.session()` allocates a phone, connects a driver, and releases the phone > when the block exits, including when the code raises an exception. > > Save this as `quickstart.py`: > > ```python theme={null} > from pathlib import Path > > from axilio.platform import Client > > client = Client() > > # The context manager allocates a phone and releases it on every exit path. > with client.session("android", phone_id="ph_123") as driver: > screen = driver.observe() > print(f"Found {len(screen.texts)} text regions and {len(screen.icons)} icons") > > Path("screen.png").write_bytes(driver.screenshot()) > print("Saved screen.png") > ``` > [tab] Go SDK > > The Go client exposes the same platform resources through typed services. This > example allocates the phone explicitly and uses deferred cleanup to close the > driver and release the phone on every return path. > > Save this as `main.go`: > > ```go theme={null} > package main > > import ( > "context" > "errors" > "fmt" > "log" > "os" > > platformgo "github.com/axilioai/platform-go" > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/drivers/mobile" > "github.com/axilioai/platform-go/option" > ) > > func main() { > if err := run(); err != nil { > log.Fatal(err) > } > } > > func run() error { > apiKey := os.Getenv("AXILIO_API_KEY") > if apiKey == "" { > return errors.New("AXILIO_API_KEY is not set") > } > > ctx := context.Background() > c := client.NewClient( > option.WithAPIKey(apiKey), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > > session, err := c.Phones.Allocate(ctx, &platformgo.PhoneAllocateRequest{ > PhoneType: platformgo.PhoneAllocateRequestPhoneTypeAndroid, > PhoneID: platformgo.String("ph_123"), > }) > if err != nil { > return fmt.Errorf("allocate phone: %w", err) > } > defer func() { > if _, err := c.Phones.Deallocate(context.Background(), &platformgo.PhonesDeallocateRequest{ > PhoneID: session.PhoneID, > }); err != nil { > log.Printf("release phone: %v", err) > } > }() > > if session.ControlURL == nil { > return errors.New("allocation returned no control URL") > } > > driver := mobile.ConnectRemote(*session.ControlURL) > defer func() { _ = driver.Close() }() > > screen, err := driver.Observe() > if err != nil { > return fmt.Errorf("observe screen: %w", err) > } > fmt.Printf("Found %d text regions and %d icons\n", len(screen.Texts), len(screen.Icons)) > > png, err := driver.Screenshot() > if err != nil { > return fmt.Errorf("capture screenshot: %w", err) > } > if err := os.WriteFile("screen.png", png, 0o600); err != nil { > return fmt.Errorf("save screenshot: %w", err) > } > fmt.Println("Saved screen.png") > return nil > } > ``` Use the same client to manage phones, sessions, runs, files, usage, billing, organizations, and API keys. Use the [CLI](/cli) when you prefer the equivalent operations in a terminal, or use the [REST API](/api-reference/overview) when you need direct HTTP access. ## 4. Run it > [tab] Python SDK > > ```bash theme={null} > python quickstart.py > ``` > > On Windows, use `py quickstart.py` if `python` is not on your `PATH`. > [tab] Go SDK > > ```bash theme={null} > go run . > ``` You now have a complete allocation lifecycle: authenticate, reserve a phone, connect a mobile driver, observe the screen, save a screenshot, and release the phone on normal and error exits. > [!TIP] > > Prefer the terminal? Follow the separate [CLI guide](/cli). > The CLI can allocate and drive a phone without an SDK project. ## Next steps > [card] [Control a phone](/driver/overview) > > Find screen elements and tap, type, swipe, or wait for them. > [card] [Phones and Sessions](/devices/overview) > > Choose phones and understand allocation behavior. > [card] [Python SDK on GitHub](https://github.com/axilioai/platform-python) > > Browse the Python client, mobile driver, and examples. > [card] [Go SDK on GitHub](https://github.com/axilioai/platform-go) > > Browse the Go client, mobile driver, and package reference. # Configuration Source: https://docs.axilio.ai/reference/configuration Configure the CLI, Python SDK, and Go SDK, including precedence, environment variables, retries, and local state. > [tab] CLI > > Axilio reads CLI configuration from command-line flags, environment > variables, and saved local state. More specific values win. > > ## Inspect the effective state > > ```bash theme={null} > axilio config > axilio status > axilio doctor > ``` > > * `axilio config` prints the effective API host, authentication source, > active organization, config path, and sessions directory without making > a request. > * `axilio status` verifies the effective credential against the API. > * `axilio doctor` checks authentication, connectivity, account state, the > CLI version, configuration paths, and the selected session. > > ## Save or clear values > > | Value | Set it | Clear it | > | - | - | - | > | Browser OAuth session | `axilio login` | `axilio logout` | > | API key | Pipe the key to `axilio login` on standard input | `axilio logout` | > | API host | `axilio config set base-url https://api.axilio.ai` | `axilio config unset base-url` | > | Active organization | `axilio orgs use ` | `axilio orgs clear` | > > For a non-interactive API-key login, keep the value out of command history: > > ```bash macOS and Linux theme={null} > printf '%s\n' "$AXILIO_API_KEY" | axilio login > ``` > > ```powershell Windows (PowerShell) theme={null} > $env:AXILIO_API_KEY | axilio login > ``` > > Pass an API origin without `/api/v1` to the CLI. Authentication and > organization selection use their dedicated commands. > > ## Resolution precedence > > The first non-empty value wins: > > | Setting | Precedence | > | - | - | > | Credentials | `--api-key` → `AXILIO_API_KEY` → saved API key → saved OAuth session | > | API host | `--base-url` → `AXILIO_BASE_URL` → saved `base-url` → `https://api.axilio.ai` | > | Organization | `--org` → `AXILIO_ORG` → saved organization → OAuth default | > | Phone session | `--session` → `AXILIO_SESSION` → only locally saved session → most recently started session → ambiguity error | > > An API key belongs to one organization. Organization selection applies to > a browser OAuth session, not to an API key from a different organization. > > ## Global flags > > | Flag | What it does | > | - | - | > | `--api-key ` | Use an API key for one command without saving it. Prefer the environment variable to keep secrets out of shell history. | > | `--base-url ` | Override the API origin for one command. | > | `--org ` | Select an organization for one OAuth-backed command. | > | `-o, --output ` | Choose table or JSON output. | > | `-q, --quiet` | Suppress acknowledgments, notes, progress, and prompts while retaining result data, warnings, and errors. | > | `--no-color` | Disable ANSI color in human output. | > > Phone commands also accept `--session `. Run > `axilio --help` for command-specific flags. > > ## Environment variables > > | Variable | What it does | > | - | - | > | `AXILIO_API_KEY` | Authenticates API requests. | > | `AXILIO_BASE_URL` | Overrides the API origin without `/api/v1`. | > | `AXILIO_ORG` | Selects an OAuth organization by slug or ID. | > | `AXILIO_SESSION` | Selects a locally saved session for `axilio phone` commands. | > | `AXILIO_DASHBOARD_URL` | Overrides the Dashboard origin used for browser OAuth. | > | `XDG_CONFIG_HOME` | Moves the Axilio configuration directory. | > | `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE`, `TERM` | Control ANSI color in CLI help using the conventional terminal rules. | > > ## Per-command overrides > > The CLI exposes API-key, base-URL, and organization overrides as global > flags. It does not expose per-request HTTP retry or deadline options. > > ## Local state > > Unless `XDG_CONFIG_HOME` is set, CLI state lives under > `~/.config/axilio`. On Windows that is `%USERPROFILE%\.config\axilio`. > > | Location | Contents | > | - | - | > | `config.json` | Saved API host, API-key state, and active organization. | > | OS keychain | Preferred storage for browser OAuth under service `axilio-cli`: the macOS Keychain, Windows Credential Manager, or the Linux Secret Service. | > | `oauth.json` | OAuth fallback when the OS keychain is unavailable. Created with mode `0600` on macOS and Linux. | > | `sessions/.json` | Saved connection details used by `axilio phone`. | > | `current-session` | Most recently started local session. | > | `update-check.json` | Daily CLI release-check cache. | > > Deleting local state does not release a remote phone. Stop the session > first: > > ```bash theme={null} > axilio sessions stop --yes > ``` > [tab] Python SDK > > The Python `Client` reads its defaults from environment variables. Explicit > client options take precedence. > > ## Configure a client > > ```python theme={null} > from axilio.platform import Client > > client = Client( > api_key="axl_...", > base_url="https://api.axilio.ai", > argus_base_url="https://argus.axilio.ai", > timeout=30.0, > max_retries=3, > ) > ``` > > | Option | Default | What it does | > | - | - | - | > | `api_key` | `AXILIO_API_KEY` | Authenticates Platform and Argus requests. | > | `base_url` | `AXILIO_BASE_URL`, then `https://api.axilio.ai` | Sets the Platform origin; the client appends `/api/v1`. | > | `argus_base_url` | `AXILIO_ARGUS_BASE_URL`, then `https://argus.axilio.ai` | Sets the Argus origin. | > | `timeout` | `30.0` | Request timeout in seconds. | > | `max_retries` | `3` | Maximum automatic retries for transient request failures. | > > The ergonomic `Client` above configures both the Platform API and Argus. > For direct access to the generated Platform resources, use `AxilioApi` or > its awaitable counterpart, `AsyncAxilioApi`: > > ```python theme={null} > from axilio import AxilioApi > > api = AxilioApi( > api_key="axl_...", > base_url="https://api.axilio.ai/api/v1", > timeout=60, > max_retries=2, > ) > ``` > > Both generated clients accept `base_url` or `environment`, plus `api_key`, > default `headers`, `timeout`, `max_retries`, `follow_redirects`, `logging`, > and a custom `httpx_client`. They also accept > `stream_reconnection_enabled` (default `true`) and > `max_stream_reconnection_attempts` (unlimited by default) for resumable > streaming endpoints. `AsyncAxilioApi` takes an `httpx.AsyncClient`; the > synchronous client takes an `httpx.Client`. An explicit `base_url` wins > over `environment`. > > ## Environment variables > > | Variable | What it does | > | - | - | > | `AXILIO_API_KEY` | Authenticates Platform and Argus requests. | > | `AXILIO_BASE_URL` | Overrides the Platform API origin without `/api/v1`. | > | `AXILIO_ARGUS_BASE_URL` | Overrides the Argus origin. | > > Hosted runs inject their own runtime configuration. Do not set or depend > on their internal variables in local or CI configuration. > > ## Per-request overrides > > Generated resource methods accept `request_options`: > > ```python theme={null} > phones = client.phones.list( > ownership="dedicated", > request_options={ > "timeout_in_seconds": 10, > "max_retries": 1, > }, > ) > ``` > > Current request keys include `timeout_in_seconds`, `max_retries`, > `additional_headers`, `additional_query_parameters`, > `additional_body_parameters`, `chunk_size`, > `stream_reconnection_enabled`, and `max_stream_reconnection_attempts`. > The last two apply only to resumable streaming endpoints. Request values > override client defaults for that call. > [tab] Go SDK > > The Go SDK does not read Axilio environment variables automatically. Read > configuration using your application's preferred method and pass it to the > client: > > ## Configure a client > > ```go theme={null} > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > _ = api // Reuse this client for Platform requests. > ``` > > Unlike the CLI and Python `Client`, Go's `option.WithBaseURL` expects the > complete API base path ending in `/api/v1`. > > | Option | What it does | > | - | - | > | `option.WithAPIKey` | Sets API-key authentication. | > | `option.WithBaseURL` | Sets the complete API base URL. | > | `option.WithHTTPClient` | Supplies a custom HTTP client. | > | `option.WithHTTPHeader` | Adds headers to requests. | > | `option.WithMaxAttempts` | Sets total HTTP attempts. Default: `2`. | > | `option.WithMaxStreamBufSize` | Sets the largest streaming message buffer. Default: 1 MB. | > | `option.WithoutRetries` | Disables automatic retries. | > | `option.WithBodyProperties` | Adds generated request-body properties. | > | `option.WithQueryParameters` | Adds generated query parameters. | > > If you choose to read `AXILIO_API_KEY` in your Go program, pass its value > to `option.WithAPIKey` as shown above. > > Hosted runs inject their own runtime configuration. Do not set or depend > on their internal variables in local or CI configuration. > > ## Per-request overrides > > Use a context for the deadline and pass request options after the request: > > ```go theme={null} > requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second) > defer cancel() > > phones, err := api.Phones.List( > requestCtx, > &platformgo.PhonesListRequest{ > Ownership: platformgo.PhonesListRequestOwnershipDedicated, > }, > option.WithMaxAttempts(4), > ) > if err != nil { > log.Fatal(err) > } > fmt.Println(phones.Total) > ``` ## Next steps > [card] [Authentication](/authentication) > > Sign in or configure an organization-scoped API key. > [card] [Client](/driver/client) > > Open a phone session and configure driver defaults. > [card] [Errors](/reference/errors) > > Recover from API, control, run, and live-view failures. # Errors and recovery Source: https://docs.axilio.ai/reference/errors Classify API, phone-control, run, and Dashboard live-video failures and choose a safe recovery. Axilio reports failures at four customer-visible layers: 1. **API errors** reject an HTTP request. 2. **Phone-control errors** occur over the Device Control Protocol. 3. **Run failures** happen after a run was accepted. 4. **Dashboard live-video errors** affect the browser viewer separately from SDK or CLI control. Identify the layer before retrying. A failed live-video connection, for example, does not necessarily mean the phone session or DCP control path failed. ## API errors HTTP failures from the REST API and the Argus API return an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details body, with the content type `application/problem+json`. Branch on the status, and for Argus also on the `code`, never on the human-readable text. See [Problem details](#problem-details) for the full shape. | Status | Meaning | Recovery | | - | - | - | | `400 Bad Request` | A business rule or input check failed. | Read the detail, correct the request, and do not retry it unchanged. | | `401 Unauthorized` | The credential is missing, invalid, or expired. | Run `axilio login` or supply a valid organization-scoped API key. | | `402 Payment Required` | A quota blocks the operation: the balance can't cover starting a run, or the organization's file library or a phone's pending file pushes are full. | Read the detail. Add funds in [**Billing**](https://app.axilio.ai/dashboard/axilio/billing), or free up the quota the detail names. | | `403 Forbidden` | The caller lacks the required organization permission. | Select the correct organization or ask an administrator for access. | | `404 Not Found` | The resource does not exist or is not visible to the organization. | Verify the ID and organization. | | `408 Request Timeout` | The request exceeded a server or gateway deadline. | Retry with backoff only when the operation is safe to repeat. | | `409 Conflict` | Current state conflicts with the request, such as a busy phone, a duplicate active name, a run started while the organization is at its concurrency limit, a follow-up after a run's phone hold ended, or a shared-pool allocation the balance can't cover. | Refresh the resource, resolve the conflict, and try again. For a low balance, add funds first. | | `405 Method Not Allowed` | The path does not accept this HTTP method. | Check the method against the reference page. | | `422 Unprocessable Entity` | The body or query does not match the endpoint schema. | Correct the fields identified by the validation errors. | | `429 Too Many Requests` | The caller exceeded a rate limit. | Honor retry guidance and back off. | | `500 Internal Server Error` | Axilio encountered an unexpected failure. | Retry once with backoff; report it if it persists. | | `502 Bad Gateway` | A phone, or the service in front of it, rejected the operation. | Read the detail and correct the upstream condition. | | `503 Service Unavailable` | A service is temporarily unavailable. | Retry with backoff. | | `504 Gateway Timeout` | A phone, or the service in front of it, missed its deadline. | Retry later; use a new session if the current one cannot recover. | ### Low balance To start a run, allocate a shared-pool phone, or make a billable Argus call, you need a balance above zero. If your balance is zero or negative, the status depends on the call: | Call | Status | `detail` | | - | - | - | | Start a run | `402` | `insufficient balance to start a run` | | Allocate a shared-pool phone | `409` | `insufficient balance. a positive balance is required, available: $` | | Billable Argus call | `402` | `insufficient balance` | When you allocate a dedicated phone, Axilio skips the balance check because your organization already pays for the rental. A `409` from allocation can also mean the phone is busy. The `detail` tells a person which one it is, but don't parse it in code. After a `409`, read your balance with [**Get account balance**](/api-reference/rest/billing/get-account-balance) and add funds if it is zero or less. ### Problem details Both APIs return the RFC 9457 members `type`, `title`, `status` and `detail`. Argus adds a machine-readable `code`. An Argus error looks like this: ```json theme={null} { "type": "about:blank", "title": "Unprocessable Entity", "status": 422, "detail": "body.query: String should have at least 1 character", "code": "validation", "errors": [ { "location": "body.query", "message": "String should have at least 1 character" } ] } ``` | Member | Meaning | | - | - | | `type` | Always `about:blank`. | | `title` | The HTTP reason phrase for `status`. | | `status` | The HTTP status code. | | `detail` | A human-readable explanation. For internal errors this is a generic message, never internal details. | | `code` | Argus only. A stable, machine-readable category. | | `errors` | Present on a `422` request-validation problem: one entry per field, with its `location` and `message`. REST API entries also carry the rejected `value`. | The `code` values you can see from Argus: | `code` | Typical status | Meaning | | - | - | - | | `validation` | `400`, `422` | The request is invalid, such as a malformed image, an unsupported model, or a field that fails the schema. | | `unauthorized` | `401` | The API key is missing or invalid. | | `quota_exceeded` | `402` | The organization's balance cannot cover the call. | | `permission_denied` | `403` | The credential is not allowed to perform the operation. | | `not_found` | `404` | The path or resource does not exist. | | `method_not_allowed` | `405` | The path exists but not with this HTTP method. | | `conflict` | `409` | The request conflicts with current state. | | `unprocessable_entity` | `422` | The request is well formed but breaks a business rule. | | `upstream_rejection` | `502` | The model provider failed or rejected the call. | | `infrastructure` | `500` | An Argus model or inference component failed. | | `error` | `500` | An unexpected internal failure. | The REST API has no `code` member: branch on `status`, and read `errors` for the fields a validation problem names. > [tab] CLI > > The CLI writes errors to standard error and returns a stable exit status: > > ```bash macOS and Linux theme={null} > axilio runs get run_123 > status=$? > > case "$status" in > 0) echo "success" ;; > 2) echo "invalid command or request" ;; > 3) echo "authentication or permission failure" ;; > 4) echo "not found" ;; > 5) echo "timed out" ;; > 6) echo "service or phone unavailable" ;; > 7) echo "canceled" ;; > *) echo "unclassified failure" ;; > esac > ``` > > ```powershell Windows (PowerShell) theme={null} > axilio runs get run_123 > > switch ($LASTEXITCODE) { > 0 { "success" } > 2 { "invalid command or request" } > 3 { "authentication or permission failure" } > 4 { "not found" } > 5 { "timed out" } > 6 { "service or phone unavailable" } > 7 { "canceled" } > default { "unclassified failure" } > } > ``` > [tab] Python SDK > > ```python theme={null} > from axilio.platform import ApiError > > try: > run = client.runs.get("run_123") > except ApiError as error: > print(error.status_code, error.body) > if error.status_code in {429, 500, 503, 504}: > print("retry with backoff when the operation is safe") > else: > raise > ``` > [tab] Go SDK > > ```go theme={null} > _, err := api.Runs.Get(ctx, &platformgo.RunsGetRequest{ > RunID: "run_123", > }) > if err != nil { > var apiErr *core.APIError > if errors.As(err, &apiErr) { > log.Printf("status=%d", apiErr.StatusCode) > } > return err > } > ``` > [tab] REST API > > REST API References: > > * [**Get a run**](/api-reference/rest/runs/get-a-run): `GET /runs/{run_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body --include \ > --url https://api.axilio.ai/api/v1/runs/run_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ### CLI exit statuses Branch on the status rather than parsing human-readable error text. | Status | Meaning | | - | - | | `0` | Success. | | `1` | Generic failure, including HTTP `402` and `409`. | | `2` | Invalid syntax, arguments, values, or HTTP `400`/`422`. | | `3` | Missing or rejected credentials, or HTTP `401`/`403`. | | `4` | Resource or active allocation not found, or HTTP `404`. | | `5` | Timeout, including HTTP `408` and an on-screen target that never appears. | | `6` | Service or phone unavailable, rate-limited, or HTTP `429`/`5xx`. | | `7` | Canceled by the user, shell, or system. | ## Phone-control errors Python maps stable phone-control error codes to exception classes. Go returns `*mobile.Error` with `Code`, `Message`, and `Retryable`. The CLI maps them to the exit statuses above. | Code | Python exception | CLI exit | Meaning | Retryable by default | | - | - | - | - | - | | `unknown_op` | `UnknownOpError` | `1` | The executor does not recognize the operation. | No | | `invalid_args` | `InvalidArgsError` | `2` | The command parameters are invalid. | No | | `no_allocation` | `NoAllocationError` | `4` | No active allocation exists for the command. | No | | `not_connected` | `NotConnectedError` | `6` | The executor cannot reach the phone agent. | No | | `device_offline` | `DeviceOfflineError` | `6` | The phone is temporarily unavailable. | Yes | | `unauthorized` | `UnauthorizedError` | `3` | The session credential was rejected. | No | | `internal` | `InternalError` | `1` | The executor encountered an unclassified failure. | No | | `canceled` | `CanceledError` | `7` | The operation context or deadline was canceled. | No | | `connection` | `ConnectionError` | `6` | The control transport exhausted its bounded reconnect budget. | Yes | | `session_ended` | `SessionEndedError` | `1` | The session ended or the connection was superseded. | No | | `control_held` | `ControlHeldError` | `1` | Another controller holds the session's control lease. | No | | `timeout` | `TimeoutError` | `5` | A command exceeded its deadline. | Yes | | `action_timeout` | `ActionTimeoutError` | `5` | A locator's target never became actionable within its timeout, or an action found several matches on the screen. Also a built-in `TimeoutError` in Python. | No | | `strategy_unavailable` | `StrategyUnavailableError` | `2` | The locator asks for a resolution strategy this session doesn't offer. | No | Prefer the `retryable` value carried by the actual error over a table default. The SDK transport automatically attempts bounded reconnect and cursor resume before it surfaces a connection error. > [tab] CLI > > ```bash macOS and Linux theme={null} > axilio phone find "the Continue button" --timeout 5s > status=$? > > case "$status" in > 0) echo "found" ;; > 5) echo "observe again or refine the description" ;; > 6) echo "retry if the session is still active" ;; > *) echo "inspect stderr" ;; > esac > ``` > > ```powershell Windows (PowerShell) theme={null} > axilio phone find "the Continue button" --timeout 5s > > switch ($LASTEXITCODE) { > 0 { "found" } > 5 { "observe again or refine the description" } > 6 { "retry if the session is still active" } > default { "inspect stderr" } > } > ``` > [tab] Python SDK > > ```python theme={null} > from axilio.drivers.mobile import ActionTimeoutError, AxilioError > > with client.session("android", phone_id="ph_123") as driver: > try: > driver.locator(query="the Continue button").tap(timeout=5) > except ActionTimeoutError: > print("observe again or refine the description") > except AxilioError as error: > print(error.code, error.retryable, error.message) > if not error.retryable: > raise > ``` > [tab] Go SDK > > ```go theme={null} > _, err := driver.Locator(mobile.Query("the Continue button")).Tap(mobile.WithTimeout(5 * time.Second)) > if err != nil { > if mobile.IsActionTimeout(err) { > log.Print("observe again or refine the description") > return nil > } > var driverErr *mobile.Error > if errors.As(err, &driverErr) { > log.Printf("code=%s retryable=%t", driverErr.Code, driverErr.Retryable) > } > return err > } > return nil > ``` For `session_ended`, create or select a live session. For `control_held`, close or coordinate with the existing controller instead of retrying against the one-controller guardrail. See [Reconnect and resume](/api-reference/dcp/reconnect) for the transport contract. ## Run failures Starting a run can succeed while the run later fails waiting for a phone or while it runs. A successful fetch of a failed run still returns HTTP `200` and CLI exit status `0`; branch on the run's `status` field. > [tab] CLI > > ```bash theme={null} > axilio runs get run_123 -o json > ``` > [tab] Python SDK > > ```python theme={null} > run = client.runs.get("run_123") > if run.status == "failed": > print(run.error) > ``` > [tab] Go SDK > > ```go theme={null} > run, err := api.Runs.Get(ctx, &platformgo.RunsGetRequest{ > RunID: "run_123", > }) > if err != nil { > log.Fatal(err) > } > if run.GetStatus() == platformgo.RunResponseStatusFailed { > if reason := run.GetError(); reason != nil { > log.Print(*reason) > } > } > ``` > [tab] REST API > > REST API References: > > * [**Get a run**](/api-reference/rest/runs/get-a-run): `GET /runs/{run_id}` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url https://api.axilio.ai/api/v1/runs/run_123 \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" | > jq '{status, error}' > ``` Use `error` for the terminal reason. It was named `error_message` before API 0.151.0. An agent-mode run also lists each step in [`GET /runs/{run_id}/steps`](/agents/monitor#read-the-runs-steps), with the step's own `error`. Inspect the run's [Telemetry](/telemetry/overview) and [retained record](/telemetry/retrieve) for the execution sequence and output logs. ## Dashboard live-video errors The Dashboard can report that it could not reach the live phone, lost the connection, or reached the live-viewer limit. These messages describe the browser's WebRTC video and input path, not the DCP connection used by the CLI and SDKs. After an established video connection drops, the Dashboard retries up to three times before presenting **Reconnect**. A viewer-limit message means the session already has four connected viewers, counting the Dashboard, hosted links, and embedded viewers together; close one before reconnecting. See [Viewer limit](/observability/live-view#viewer-limit). The CLI and SDKs do not expose this Dashboard live-video state. If you already control the same session, test the independent DCP path: > [tab] CLI > > ```bash theme={null} > axilio sessions current > axilio phone observe > ``` > [tab] Python SDK > > ```python theme={null} > screen = driver.observe() > ``` > [tab] Go SDK > > ```go theme={null} > screen, err := driver.Observe() > if err != nil { > log.Fatal(err) > } > fmt.Println(screen.Hash) > ``` If control succeeds, only the browser viewer needs recovery. If it fails, use the DCP error above. A session created only in the Dashboard is not automatically attached to a separate CLI process. ## Report a persistent problem Include the method or command, error status or code, affected resource IDs, approximate time, and whether the live-video path, control path, or both were affected. Do not share API keys, session credentials, `control_url`, `telemetry_url`, or `live_view_url`. ## Next steps > [card] [Configuration](/reference/configuration) > > Configure credentials, retries, timeouts, and client state. > [card] [Session lifecycle](/devices/timeouts) > > Understand session and connection deadlines. > [card] [Live view](/observability/live-view) > > Protect and recover the browser viewer. # Telemetry data controls Source: https://docs.axilio.ai/telemetry/data-controls Control Telemetry capture, understand access retention, and protect sensitive session data. Telemetry can include console output, operation attributes, and captured SDK inputs or results. Decide whether to enable it as part of your session's data handling policy. ## Enable or disable Telemetry Telemetry is enabled by default. Set `telemetry: false` on a direct allocation, or `telemetry_enabled: false` on an agent or an ad hoc run, when you do not want Axilio to create a live or retained Telemetry record for the resulting session. A run's [stream](/agents/monitor#stream-a-run) still carries the run's own events and the model's text live, and keeps none of it. ```json theme={null} { "telemetry": false } ``` For an agent or an ad hoc run: ```json theme={null} { "telemetry_enabled": false } ``` This setting is all-or-nothing: * no frames are available from the live Telemetry stream; * **Timeline** and **Console** have no Telemetry to display; and * no retained frame archive is available after the session. > [!WARNING] > > There is no live-only Telemetry mode. Copy that says Telemetry remains > available live when storage is disabled is outdated. Recording is a separate control, and it is off by default. Leaving recording off does not disable Telemetry, and `telemetry: false` does not disable a recording. | Configuration | Telemetry | Recording | | - | - | - | | Defaults | Live and retained | Not captured | | `recording: true` | Live and retained | Captured | | `telemetry: false` | Not created | Not captured | | `recording: true` and `telemetry: false` | Not created | Captured | ## Understand the access window How long you can read a session's Telemetry depends on your [organization plan](/billing/overview#organization-plans) when the session started. These are the standard windows: If you change plans later, sessions that already exist keep their window. Daily, Weekly, and Monthly dedicated rentals don't change it either. An Enterprise contract can set a different window, and the contract's window is the one that applies. The retention clock begins when the session is created, not when it ends or when the last frame arrives. Read `retention_expired` from a retained [frames response](/telemetry/retrieve) before interpreting an empty archive. Once the window closes, the frames response returns no frames and sets `retention_expired` to `true`. A daily cleanup job then deletes the stored Telemetry. Deletion happens on a later run of that job, not at the instant access ends. > [!NOTE] > > The access period is not a guaranteed minimum. Access enforcement and > physical cleanup are separate lifecycle operations. If Axilio cannot establish that the session remains inside its access window, the retention check fails safe and withholds the trace. An explicit organization policy can disable Telemetry writing. Other configurations can leave a session with no positive access window. Customer access can then expire immediately even when frames may have been emitted. Do not infer the session's physical storage history from an empty or expired response. ## Keep session history after agent deletion Deleting an agent does not delete the sessions its runs created. Session details and retained Telemetry remain addressable by `session_id`, subject to the normal retention policy. You can no longer mint a live Telemetry URL after the session ends. Existing history remains a session resource rather than a child of the deleted agent. ## Know what Telemetry can contain Raw frames can contain producer attributes and data supplied by your SDK calls. SDK-call spans use these attributes: | Attribute | Content | | - | - | | `axilio.sdk.input` | The captured input parameters. | | `axilio.sdk.output` | The captured result. | Each nonempty input and output value is capped independently at 4,096 bytes. A truncation marker indicates when the value exceeded the cap. Truncation is a size control, not a privacy control. Current capture behavior is: * `Keyboard.typeText` input replaces its `text` value with ``, where `N` is the character count; * malformed typed-text input is replaced with a placeholder; * other nonempty SDK inputs and all nonempty SDK results can be captured verbatim; * empty bytes, `{}`, and `null` are omitted; and * transport operations such as `Protocol.handshake` and `Device.info` do not create SDK-call spans. > [!WARNING] > > Redaction is not schema-driven. A typed request does not imply that its > sensitive fields are removed. There is no separate switch for SDK-call input/output capture. To prevent these values from entering the Telemetry record, disable Telemetry for the session. Otherwise, avoid putting secrets or unnecessary sensitive data in SDK parameters, return values, or custom attributes. The Dashboard details pane renders a curated set of fields. A value can exist in a [raw frame](/telemetry/frames) without appearing in **Timeline** details. ## Protect Telemetry access Retained frames and the event stream accept your customer API key and require organization viewer access. A session outside your organization is reported the same way as a missing session. A live `telemetry_url` is a session-scoped bearer secret: * do not write it to application logs; * do not include it in screenshots, analytics, or support bundles; * keep it out of browser history and referrer data; * do not share it as a device-control or recording credential; and * discard it when you no longer need it. The URL is read-only, scoped to one session, and expires about an hour after it is issued. Read the session again for a fresh one. See [Live telemetry](/telemetry/live). ## Data-handling checklist 1. Decide whether the session needs Telemetry and recording independently. 2. Keep sensitive values out of SDK inputs, outputs, and custom attributes. 3. Treat the live URL and API key as secrets. 4. Use raw frames when you need attributes that the Dashboard does not show. 5. Check `retention_expired` instead of treating every empty archive alike. 6. Base deletion commitments on your approved data policy, not the customer access-expiration timestamp. ## Next steps > [card] [Get started](/telemetry/get-started) > > Inspect Telemetry from your first session. > [card] [Retrieve telemetry](/telemetry/retrieve) > > Page through the available retained record. > [card] [Frame reference](/telemetry/frames) > > Review raw fields and compatibility rules. > [card] [Reliability](/telemetry/reliability) > > Handle empty, expired, interrupted, or incompatible data. # Telemetry frame reference Source: https://docs.axilio.ai/telemetry/frames Parse the span and log frames used by live and retained telemetry. A frame is the JSON wire representation of one telemetry span or log. Live telemetry and retained retrieval use the same tolerant envelope. Telemetry is the product concept; frames are the format you parse when you use the API, SDKs, CLI JSON output, or the raw event stream. Current producers emit two top-level frame kinds: `span` and `log`. ## Understand live and retained shapes | Record | Live stream | Retained `/frames` response | | - | - | - | | Span | Can arrive as separate `start` and `end` frames. | One completed frame with `phase: "end"`. | | Log | One point-in-time frame. | One point-in-time frame with the same envelope. | The shared schema keeps `end_time_unix_nano` and `status` optional because an in-flight live start has neither. Current retained spans contain both fields. Do not assume that every live start will receive an end frame. ## Span frames A completed SDK-call span can look like this: ```json theme={null} { "kind": "span", "phase": "end", "span_type": "sdk_call", "trace_id": "8d3a71e7ee20455a8540f8282af28d3d", "span_id": "f2d6bca40d47f56a", "parent_span_id": "4a281c89b3806610", "name": "Screen.observe", "start_time_unix_nano": 1788296400123456789, "end_time_unix_nano": 1788296400456789012, "status": { "code": "ok", "message": "" }, "attributes": { "axilio.sdk.op": "Screen.observe" } } ``` ### Span fields “Required” describes the current shared REST schema. The retained runtime is more specific where noted. | Field | Required | Meaning | | - | - | - | | `kind` | Yes | Literal `span`. | | `phase` | Yes | Current producers use `start` and `end`. Retained spans use `end`. | | `span_type` | Yes | Open product-role string. | | `trace_id` | Yes | Session trace ID. Current producers use 32 lowercase hexadecimal characters. | | `span_id` | Yes | Span identity. Current producers use 16 lowercase hexadecimal characters. | | `parent_span_id` | No | Parent span ID. Omitted on the session root. | | `name` | Yes | Operation or span name. | | `start_time_unix_nano` | Yes | Signed 64-bit nanoseconds since the Unix epoch. | | `end_time_unix_nano` | No | Completion time. Present on current retained spans and absent from an in-flight live start. | | `status` | No | Outcome object. Present on current retained spans. | | `attributes` | No | Open string-keyed producer attributes. | `status` contains required string fields `code` and `message`. Current producers use `ok` and `error`. A successful span normally has an empty message. Treat new status strings as additive values. ### Span types Current producers use these `span_type` values: | Value | Meaning | | - | - | | `session` | Root lifetime of the phone session. | | `run` | A run, or a cell run from a session. | | `model_step` | One model step of an agent-mode run. Nests under the run span. | | `agent_cell` | One cell of Python an agent-mode run executed. The SDK calls it made nest under it. | | `sdk_call` | One Axilio SDK operation. | | `inference` | Inference work, usually nested under an SDK call. | | `file_push` | File delivery to the phone. | | `media_capture` | Phone-initiated media capture. | Older retained session roots can use `phone_session`. Treat it as the older name for the session root. The vocabulary is open: render an unfamiliar value generically instead of rejecting the frame. ## Log frames An output log can look like this: ```json theme={null} { "kind": "log", "log_type": "output_log", "trace_id": "8d3a71e7ee20455a8540f8282af28d3d", "span_id": "4a281c89b3806610", "time_unix_nano": 1788296400234567890, "severity": "INFO", "body": "hello", "attributes": { "axilio.log.stream": "stdout" } } ``` ### Log fields | Field | Required | Meaning | | - | - | - | | `kind` | Yes | Literal `log`. | | `log_type` | Yes | Open product-role string. | | `trace_id` | Yes | Session trace ID. | | `span_id` | No | Containing span. Omitted for a session-level log. | | `time_unix_nano` | Yes | Signed 64-bit event time in nanoseconds since the Unix epoch. | | `severity` | Yes | Open severity string. Current producers use values such as `INFO` and `ERROR`. | | `body` | Yes | Human-readable log text. | | `attributes` | No | Open string-keyed producer attributes. | ### Log types | Value | Meaning | | - | - | | `output_log` | Standard output, standard error, or a result line. | | `output_error` | Run error details. | | `kernel_status` | Kernel lifecycle state. | | `transfer_progress` | Point-in-time file-transfer progress. | | `model_delta` | A piece of an agent-mode run's model text as it streams. Live only. | | `run_status`, `run_input_required`, `run_resumed`, `run_result`, `user_message` | A run's own events, carried on its [stream](/agents/monitor#stream-a-run). Live only. | The `log_type` vocabulary is open. Preserve, render, or ignore an unfamiliar value without failing the rest of the trace. ## Reconstruct relationships All frames for a session share a `trace_id`. A span's `parent_span_id` connects it to its parent. A log's optional `span_id` connects it to the span under which it occurred. The common hierarchy is: ```text theme={null} session └── run └── sdk_call └── inference ``` `file_push` and `media_capture` spans are normally session-level siblings of a run. An interactive SDK call outside any run can sit directly under the session. Use `span_id` as the identity when you pair a live `start` with its `end`, deduplicate a replay, or join an SDK-call cost map. Use `axilio.inference.id` to join an inference cost map. ## Read common attributes `attributes` is an extension point. Producers can add namespaced keys without changing the frame envelope. Raw API and SDK readers can see attributes that the Dashboard does not display. Common cross-cutting keys include: | Attribute | Meaning | | - | - | | `axilio.session.id` | Session identity. | | `axilio.run.id` | The run a span or log belongs to, when it belongs to one. | | `axilio.workflow.id` | The agent's ID on a saved agent's run. The attribute keeps its earlier name. | | `axilio.device.class` | Device class. Current phone sessions use `phone`. | | `axilio.device.id` | Device identity. | | `axilio.duration_ns` | Source-measured duration in nanoseconds. | | `axilio.ok` | Producer success flag on outcome-bearing spans. | | `axilio.error.code` | Categorized producer error code when available. | | `axilio.inference.id` | Inference and cost-map join key. | | `axilio.sdk.op` | SDK operation name. | | `axilio.sdk.input` | Captured and processed SDK-call input, when present. | | `axilio.sdk.output` | Captured and processed SDK-call output, when present. | Captured SDK-call input and output can contain sensitive data. See [Data controls](/telemetry/data-controls) before storing or redistributing raw attributes. ### File and media attributes These attributes can appear on `file_push` or `media_capture` spans: | Attribute | Meaning | | - | - | | `axilio.file.id` | File identity. | | `axilio.file.name` | File name. | | `axilio.file.mime_type` | MIME type. | | `axilio.file.size_bytes` | File size in bytes. | | `axilio.file.collection` | File collection, when a push specifies one. | | `axilio.capture.state` | Media-capture lifecycle state. | A `transfer_progress` log uses its `span_id` to correlate with the file push or media capture and can contain: | Attribute | Meaning | | - | - | | `axilio.transfer.direction` | Current values identify a push or capture direction. | | `axilio.transfer.bytes` | Bytes transferred so far. | | `axilio.transfer.size_bytes` | Total transfer size in bytes. | Attributes are not all required on every phase. Do not reject a frame because an activity-specific attribute is absent. ## Build a tolerant reader For every interface: * Accept unknown fields within a known frame. * Treat new `span_type`, `log_type`, severity, status, and attribute values as additive. * Preserve or render unknown data when your application needs lossless forwarding. * Validate required fields before using them. An omitted known-frame field is malformed even if a generated client model decodes it to a zero value. * In the live stream, accept either one frame object or an array of frames. Top-level `kind` compatibility currently varies by interface and version: | Reader | Current unknown-`kind` behavior | | - | - | | Raw JSON reader | Preserve an unfamiliar nonempty string `kind` and its raw JSON. | | Go SDK `v0.12.0` | Preserves the unknown string discriminator and raw JSON. | | Python live helper | Preserves an unknown string kind as an unknown frame. | | Python SDK `v0.19.0` retained reader | Can fail while parsing an unknown kind. | | Dashboard | Recognizes `span` and `log`; it does not render an unknown top-level kind. | A missing, empty, null, or non-string `kind` is malformed data, not a future frame kind. Do not describe unknown top-level kinds as universally supported until the reader you deploy has been tested for that behavior. > [!NOTE] > > The generated [REST API reference](/api-reference/rest/runs/list-a-sessions-telemetry-frames) > owns the machine-readable operation schema. This page explains field meaning > and compatibility across retained and live interfaces. ## Next steps > [card] [Retrieve telemetry](/telemetry/retrieve) > > Retrieve and paginate the retained frame envelope. > [card] [Live telemetry](/telemetry/live) > > Consume frames and cursor messages while a session is active. > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Turn frame relationships into a useful execution story. > [card] [Data controls](/telemetry/data-controls) > > Understand sensitive attributes, retention, and access. # Get started with Telemetry Source: https://docs.axilio.ai/telemetry/get-started Follow one phone session live, then inspect its available retained trace. This guide takes one phone session from live activity to its retained Telemetry record. You need a session ID and access to the session's organization. Telemetry is enabled by default for direct sessions and runs. Leave it enabled for this guide. Recording is a separate setting and is not required. ## 1. Start or choose a session Start a direct session with [Allocate a phone](/devices/allocate), start a [run](/agents/run), or choose an existing active session. Save its `session_id`. If you explicitly set `telemetry=false`, that session produces neither live nor retained Telemetry. Start another session with Telemetry enabled before continuing. ## 2. Inspect its Telemetry Choose the interface you use today. Each tab gives you a first useful view of the same session-scoped record. > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Select the active or completed session. > 3. Open **Observability**. > 4. Use **Timeline** for operations and **Console** for output and errors. > > An active session updates as work runs. When the session ends, the > Dashboard replaces its live data with retained history. > [tab] CLI > > Follow the retained prefix, then new live frames: > > ```bash theme={null} > axilio sessions trace sess_123 --follow > ``` > > After the session ends, retrieve the available trace without following: > > ```bash theme={null} > axilio sessions trace sess_123 > ``` > > Replace `sess_123` with your session ID. Add `-o json` when you need > structured output. The CLI handles retained pagination and reconciles live > data with retained Telemetry. > [tab] Python SDK > > Retrieve the available trace with the synchronous Telemetry helper: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > telemetry = client.telemetry("sess_123") > trace = telemetry.trace() > > for item in trace.spans: > print(item.frame.name, item.duration_ms) > > for entry in trace.logs: > print(entry.severity, entry.body) > ``` > > The helper retrieves every retained page. Use `telemetry.summary()` for a > summary and `telemetry.logs()` for logs only. To follow an active session, > iterate `telemetry.tail()`. See [Follow live Telemetry](/telemetry/live) for the > complete live example. > [tab] Go SDK > > Retrieve the available trace with the Telemetry helper: > > ```go theme={null} > view := telemetry.NewSession(api, "sess_123") > > trace, err := view.Trace(ctx) > if err != nil { > log.Fatal(err) > } > > for _, item := range trace.Spans { > fmt.Println(item.Span.Name, item.Duration()) > } > for _, entry := range trace.Logs { > fmt.Println(entry.Severity, entry.Body) > } > ``` > > Import the helper from > `github.com/axilioai/platform-go/drivers/telemetry`. It retrieves every > retained page. Use `telemetry.Tail` with the session's stream URL to follow an active session; > see [Follow live Telemetry](/telemetry/live) for the complete stream loop. > [tab] REST API > > REST API reference: > > * [**List a session's Telemetry frames**](/api-reference/rest/runs/list-a-sessions-telemetry-frames): `GET /phones/sessions/{session_id}/frames` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request GET \ > --url 'https://api.axilio.ai/api/v1/phones/sessions/sess_123/frames?limit=1000&offset=0' \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > | jq > ``` > > The response contains a page of `frames`, pagination fields, > response-level cost maps, and `retention_expired`. Continue through the > pages instead of assuming the first response contains the whole session. > > For active streaming, read `GET /realtime/sessions/{session_id}/events` > (Server-Sent Events) with the same key; see [Live Telemetry](/telemetry/live). ## 3. Follow the live-to-retained handoff The live stream starts with the retained trace so far and closes when the session ends. After that, retained retrieval by the same `session_id` holds the record. The Dashboard and CLI reconcile the live view with retained history. The SDK helpers expose both paths so your application can do the same. If a custom reader suspects a live gap, retrieve all available retained pages before treating the local view as reconciled. > [!NOTE] > > Telemetry is best effort. Live delivery can replay frames or contain gaps, > and a frame seen live does not prove that an identical record was retained. ## 4. Find the useful signal * Use **Timeline** or trace spans to see what ran, how operations nested, and which operations failed. * Use **Console** or trace logs to inspect output and errors. Run output is Telemetry; it is not a field on the run response. * Read `retention_expired` before interpreting an empty retained result. * Treat billed cost as point-in-time context. Do not infer that a missing key or numeric zero means an operation was definitively free. > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Interpret Timeline, Console, relationships, status, and cost context. > [card] [Retrieve Telemetry](/telemetry/retrieve) > > Learn pagination, ordering, empty states, and response-level cost maps. > [card] [Data controls](/telemetry/data-controls) > > Understand what is captured and how long it is normally accessible. > [card] [Troubleshoot Telemetry](/telemetry/reliability) > > Diagnose gaps, duplicates, disabled capture, expiration, and compatibility. # Live telemetry Source: https://docs.axilio.ai/telemetry/live Follow a session through the Dashboard, CLI, SDKs, or the read-only telemetry event stream. Live telemetry gives you progressive visibility into a phone session. It uses the same span-and-log frame envelope that you can [retrieve later](/telemetry/retrieve). The stream is Server-Sent Events (`text/event-stream`): it starts with the retained trace so far, then follows new frames as they happen, and closes when the session ends. To follow one run, including its pauses, its result and the model's text, read the run's `stream_url` instead. See [Stream a run](/agents/monitor#stream-a-run). ## Follow a session > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Select an active session. > 3. Click **Observability**. > > **Timeline** pairs span starts and ends as work progresses. **Console** > displays output and error logs. Select a timeline row to inspect its > currently available details. > > When the session ends, the Dashboard replaces the live view of the trace > with retained history. > [tab] CLI > > Print the retained prefix, then follow new frames until the session ends: > > ```bash theme={null} > axilio sessions trace sess_123 --follow > ``` > > Follow a run from its queue state through its final outcome: > > ```bash theme={null} > axilio runs watch run_123 > ``` > > Add `-o json` for newline-delimited JSON. `sessions trace --follow` emits > one frame per line and finishes with: > > ```json theme={null} > {"trace_end":true,"session_id":"...","frames":0,"session_ended":true,"sdk_call_costs":{},"inference_costs":{}} > ``` > > `frames` is the number of distinct delivered frames. `runs watch -o json` > ends with: > > ```json theme={null} > {"error":null,"run_id":"...","status":"completed","watch_end":true} > ``` > > `runs watch` exits with 0 for completed, 1 for failed, and 7 for cancelled > or locally interrupted work. The CLI handles resume, replay > deduplication, and retained reconciliation. > [tab] Python SDK > > Follow a session by its ID with your client's API key: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > telemetry = client.telemetry("sess_123") > > for frame in telemetry.tail(open_timeout=10): > print(frame) > ``` > > The loop gets the trace so far, then live frames, and ends on its own when > the session ends. If your process allocated the phone, you can pass the > allocation's `telemetry_url` as the second argument instead. The helper > resumes after a drop with `Last-Event-ID`. Use `telemetry.logs(live=True)` > when you only need logs. > > `open_timeout` covers opening the stream. Iteration can wait for the next > frame while the session remains active. > > The high-level helper is synchronous. A rejected credential raises > `UnauthorizedError`, an unknown session raises `SessionNotFoundError`, a > session with Telemetry off raises `TelemetryOffError`, and an exhausted > reconnect sequence raises a retryable `ConnectionError`. > [tab] Go SDK > > Open the stream with the session's URL and your API key: > > ```go theme={null} > package main > > import ( > "context" > "errors" > "fmt" > "io" > "log" > "net/http" > "os" > > "github.com/axilioai/platform-go/drivers/telemetry" > ) > > func main() { > ctx := context.Background() > url := telemetry.StreamURL("https://api.axilio.ai/api/v1", "sess_123") > key := http.Header{"X-Axilio-Api-Key": {os.Getenv("AXILIO_API_KEY")}} > > stream, err := telemetry.Tail(ctx, url, telemetry.WithHeader(key)) > if err != nil { > log.Fatal(err) > } > defer stream.Close() > > for { > frame, err := stream.Next(ctx) > if errors.Is(err, io.EOF) { > break > } > if err != nil { > log.Fatal(err) > } > fmt.Printf("%+v\n", frame) > } > } > ``` > > Import the helper from > `github.com/axilioai/platform-go/drivers/telemetry`. An allocation's > `telemetry_url` works too, with no header. The stream reopens with > `Last-Event-ID` after a drop. > > `Stream.Next(ctx)` returns the next frame and `io.EOF` once the session is > over; `Ended()` then reports `true`. `Close()` stops the reader. Context > cancellation or deadline returns the context error. > > `*telemetry.Error` uses the codes `unauthorized`, `not_found`, > `telemetry_off`, `connection`, and `closed`. `Next` and `Close` can run > from different goroutines, but multiple concurrent `Next` calls are not > supported. > [tab] REST (SSE) > > Read the stream with any Server-Sent Events client. With your API key: > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --no-buffer \ > --url https://api.axilio.ai/api/v1/realtime/sessions/sess_123/events \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` > > Or use the session's `telemetry_url`, which carries a read-only token for > this one session. It comes back from `POST /phones:allocate` and from every > `GET /phones/sessions/{session_id}`, so a browser `EventSource` can open it > without headers. > > REST API reference: > > * [**Stream a session's telemetry**](/api-reference/rest/phones/stream-a-sessions-telemetry): `GET /realtime/sessions/{session_id}/events` ## Protect the URL `telemetry_url` contains a bearer credential in its query string. * Treat the entire URL as a secret. * Do not put it in application logs, analytics, screenshots, or referrer data. * Share it only with the process that reads this session's telemetry. * Discard the URL when you no longer need it. The token is scoped to one session and is read-only. It expires about an hour after it is issued; read the session again for a fresh URL. Server clients can send the API key header instead. ## Read events Each event carries one frame: ```text theme={null} id: 1761240000000-0 event: span data: {"kind":"span","phase":"start","span_type":"sdk_call",...} ``` * `id` is the event's cursor. Treat it as an opaque string. * `event` is the frame kind, such as `span` or `log`. * `data` is the frame JSON. See the [Frame reference](/telemetry/frames). Frames replayed from the retained record carry no `id`. After the replay, an event with only an `id` (no data) carries the cursor the replay ended at. Comment lines (starting with `:`) arrive every 15 seconds while the stream is idle; ignore them. Accept new fields and unfamiliar `span_type` or `log_type` values, and ignore events with an unfamiliar name. ## Resume after a drop Reconnect with the `Last-Event-ID` header set to the last `id` you received. `EventSource` does this on its own. While the cursor is inside the live window, the stream continues right after it with no gap. If the cursor is older than the live window, the stream replays the retained record first, the same as a new reader. The stream deduplicates within one connection. A reconnect that falls back to the replay can deliver frames you already have, so deduplicate on `(trace_id, span_id, phase)` for spans and on content for logs. ## When the session ends The stream sends what is left and closes. Reconnecting after that with your last cursor returns `204 No Content`, which tells `EventSource` to stop. A reader that connects after the session ended gets the retained record, then the stream closes. | Status | Meaning | | - | - | | `200` | The stream. | | `204` | The session is over and nothing is left after your cursor. Stop. | | `401` | The credential was rejected. Read the session again for a fresh `telemetry_url`. | | `404` | No such session in your organization. | | `409` | The session was started with Telemetry off. Nothing is streamed or stored. | | `503` | The server is restarting. Retry with backoff. | ## Set delivery expectations * Live delivery is best-effort. A frame dropped at publish can be missing from the live stream; the retained record is the reconciliation source. * A live frame does not prove that an identical record was retained. * Do not assume every started span receives a live end frame. * The live and retained paths are not exactly-once services. For retry classification, gap recovery, and terminal outcomes, follow [Reliability and troubleshooting](/telemetry/reliability). ## Next steps > [card] [Retrieve telemetry](/telemetry/retrieve) > > Page through retained history. > [card] [Frame reference](/telemetry/frames) > > Parse spans, logs, attributes, and additive values. > [card] [Reliability and troubleshooting](/telemetry/reliability) > > Handle interruption, duplication, and expired history. > [card] [Data controls](/telemetry/data-controls) > > Protect sensitive telemetry and understand retention. # Telemetry Source: https://docs.axilio.ai/telemetry/overview Understand Axilio's session-scoped execution record and choose how to inspect it live or after a session ends. Telemetry is the structured execution record for an Axilio phone session. It connects operations, inference work, file and media activity, output, and errors to the session where they happened. Use Telemetry to answer questions such as: * What did this session run? * Which operation took time or failed? * What did the run write to its console? * How did an SDK call relate to inference, file, or media work? * What billed-cost context is currently available for an operation? Customers use Axilio Telemetry through the Dashboard, CLI, SDK helpers, and Axilio JSON API described in this section. ## One record from live to retained A phone session is the correlation boundary for Telemetry. You address its record by `session_id`. While a session is active, you can follow new spans and logs through the Dashboard, CLI, SDK helpers, or the read-only event stream (Server-Sent Events). If you join late, the stream first replays the retained trace so far. When the session ends, the stream closes. You can then read the available retained record through the Dashboard, CLI, SDK helpers, or `GET /phones/sessions/{session_id}/frames` during its access window. First-party interfaces provide a path from live monitoring to retained reconciliation. Both access paths use the same tolerant JSON **frame** envelope. A frame is a wire representation of a span or log. Live spans can arrive in `start` and `end` phases, while the retained record represents each completed span as one completed frame. > [!NOTE] > > Telemetry delivery is best effort. A live consumer must tolerate replayed > frames and possible gaps. Reconcile with retained Telemetry after a gap, and > do not treat either path as an exactly-once or lossless audit log. ## Choose an interface | Interface | Best for | | - | - | | **Dashboard** | Visually inspect a session in **Timeline** and **Console**, then correlate the trace with a recording when one exists. | | **CLI** | Inspect a trace from a terminal with `axilio sessions trace`, or follow it with `--follow`. | | **Python SDK** | Retrieve, summarize, filter, or tail Telemetry with `client.telemetry(session_id)`. | | **Go SDK** | Retrieve or stream Telemetry with `drivers/telemetry` and context-aware iteration. | | **REST API** | Page through retained frames and join response-level cost maps in a custom integration. | | **Event stream (SSE)** | Consume the raw, read-only live stream with any Server-Sent Events client. | Start with [Get started](/telemetry/get-started) for one end-to-end session. Use the raw interfaces only when you need a custom reader or integration. ## How Telemetry differs from related features | Feature | What it tells you | | - | - | | **Telemetry** | The structured execution record: operations, relationships, timing, output, errors, and available per-operation cost context. | | [**Live view**](/observability/live-view) | What is currently visible on the phone screen. | | [**Recordings**](/observability/recordings) | A visual replay of the screen when recording was enabled and processing completed. | | [**Device Control Protocol**](/api-reference/dcp/reference) | How a client sends commands to and receives responses from an active phone. | | [**Usage**](/usage/overview) | Organization-level session and inference usage for reconciliation and reporting. | Telemetry and recording are independent controls. Disabling recording does not disable Telemetry. Starting a direct session with `telemetry=false`, or a run with `telemetry_enabled=false`, disables both live and retained Telemetry for that session. ## Explore Telemetry > [card] [Get started](/telemetry/get-started) > > Follow one session from its first live frames to its retained trace. > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Interpret operations, relationships, timing, output, errors, and cost context. > [card] [Follow live Telemetry](/telemetry/live) > > Follow a session through a first-party interface or the event stream. > [card] [Retrieve Telemetry](/telemetry/retrieve) > > Read and page through the available record by session ID. > [card] [Frame reference](/telemetry/frames) > > Parse spans, logs, identifiers, timestamps, attributes, and additive values. > [card] [Data controls](/telemetry/data-controls) > > Understand capture, retention, capability URLs, and sensitive-data boundaries. > [card] [Reliability and troubleshooting](/telemetry/reliability) > > Recover from gaps and diagnose disabled, empty, expired, or incompatible data. # Telemetry reliability and troubleshooting Source: https://docs.axilio.ai/telemetry/reliability Build a gap-aware Telemetry reader and recover from duplicates, interruptions, expiration, and compatibility differences. Telemetry provides progressive visibility into a session and a retained record for later retrieval. Both delivery paths are best effort. Build consumers that can tolerate duplicates, detect gaps, and reconcile with the available archive. Telemetry is not an exactly-once or complete audit ledger. Axilio does not publish a formal availability, completeness, replay, or durability SLA for this data. ## Use the live and retained paths together The live stream and retained archive are independent delivery paths: * a frame observed live is not proof that the same record was durably retained; * a retained span is not a byte-for-byte replay of its live `start` and `end` messages; * reconnect can replay frames you already processed; and * moving to the current live tail does not repair an earlier gap. Use live Telemetry for progressive updates. Use `GET /phones/sessions/{session_id}/frames` to reconcile after a gap and after the session ends. ## Resume without assuming exactly-once delivery Each event's `id` is its cursor. Store the last `id` only after you have processed every frame before it, and reconnect with the `Last-Event-ID` header set to it (`EventSource` does this itself). Never parse, construct, or depend on the cursor's encoding. While the cursor is inside the live window, the stream continues right after it. Otherwise the stream replays the retained record first, which can redeliver frames you already processed. A consumer that needs exactly-once presentation must deduplicate them locally. Useful identities include: * `(trace_id, span_id, phase)` for a span; * stable session, span, timestamp, and body fields for a log; and * the complete normalized JSON for an unknown frame. The SDK readers and the CLI deliver what the stream sends; the CLI deduplicates for you. ## Reconnect with a bounded budget First-party readers treat a dropped connection, a stream the server closed, and a `5xx` as transient, and reopen with bounded exponential backoff with jitter and the last cursor. For a raw client: * retry transient loss with bounded backoff and `Last-Event-ID`; * treat `204 No Content` as the end: the session is over and nothing is left; * treat `401` as a credential problem: read the session again for a fresh `telemetry_url`, or use the API key header; * treat `404` (unknown session) and `409` (Telemetry off) as terminal; and * expect a reader that cannot keep up to be disconnected, then resume from its cursor. > [!NOTE] > > These are current signals and first-party retry behavior. No public numeric > size, throughput, viewer, or buffer limit is currently published. ## Account for interface differences | Interface | Current behavior to account for | | - | - | | Dashboard | Deduplicates and pairs live span phases, then replaces live state with retained history. It retrieves only the first 1,000 retained frames. | | CLI | `sessions trace --follow` emits a retained prefix, deduplicated live frames, and a terminal marker. It reconciles across the two paths. | | Python SDK | The live reader deduplicates replay and preserves unknown string frame kinds. Released Python `v0.19.0` retained parsing can fail on expired null cost maps or an unknown top-level kind. | | Go SDK | Go `v0.12.0` preserves an unknown string kind and accepts expired null maps. A reconnect that falls back to the replay can repeat frames. | | Raw REST/SSE | Your application owns pagination, duplicate tolerance, cursor persistence, unknown-frame handling, and retry bounds. | Do not describe unreleased SDK behavior as available. Check the package version used by your application before relying on a compatibility fix. ## Troubleshoot common symptoms | Symptom | What it can mean | What to do | | - | - | - | | No live or retained frames | Telemetry was disabled, you lack access, or data was not produced. | Check the direct-allocation, agent or run Telemetry setting and your organization access. | | Retained request returns `retention_expired=true` | The record is outside its customer access window. | Treat it as expired, not as proof that the session was never observed or that every physical copy was deleted. | | Telemetry is withheld without an ordinary expired result | Axilio could not establish retention eligibility and failed safe. | Treat the failure separately from an empty trace. Retry only with a bounded policy and contact support if it persists. | | Live frames repeat after reconnect | Duplication-permitting replay. | Deduplicate before applying the frame to your local trace. | | Timeline or Console stops on a large session | The Dashboard currently reads one page of at most 1,000 frames. | Use CLI, Python, Go, or paginated REST to retrieve the complete available archive. | | An unfamiliar field or `span_type`/`log_type` appears | A forward-compatible addition. | Preserve, render generically, or ignore the value. Do not make these strings closed enums. | | An unfamiliar top-level `kind` appears | Support depends on the reader and released version. | Use a raw/tolerant path when your SDK cannot preserve it. | | SDK input/output is in raw frames but not Dashboard details | The Dashboard exposes curated fields rather than every raw attribute. | Inspect raw REST or SDK frames. | | The stream closes after completion, then answers `204` | The session is over and nothing is left after your cursor. | Stop reconnecting; the retained frames are the record. | | A run response has no `logs` field | Run output moved to Telemetry. | Use **Console**, `sessions trace`, `runs watch`, or an SDK log helper. | | An agent was deleted | Session history remains separately addressable while retained. | Query by `session_id` instead of through the agent. | | A cost is zero or absent | It can be genuinely zero, pending, unavailable, too small to register at the reported precision, omitted by a failed lookup, or outside retention. | Check map-key presence, `retention_expired`, usage `processed_status`, and finalized billing records. Do not infer “free” from zero alone. | For REST failures, parse the Problem Details response and follow [Errors](/reference/errors). A missing session and one outside your organization are intentionally indistinguishable. Do not interpret an authorization, validation, rate-limit, or internal failure as an empty trace. ## Build a tolerant reader A resilient custom reader should: 1. read one frame from each event's `data`; 2. ignore or preserve unknown fields within a known frame; 3. treat new `span_type` and `log_type` values as additive; 4. preserve an unknown nonempty string `kind` as raw JSON; 5. reject malformed data without discarding other valid frames when possible; 6. persist cursors only after processing preceding frames; 7. tolerate replayed frames; 8. paginate the retained archive to `total`; and 9. avoid relying on a deterministic order among frames with equal timestamps. See the [Frame reference](/telemetry/frames) for the current span and log shapes. ## Claims to avoid Do not build or document an assumption that: * every produced frame reaches every live subscriber; * every event appears in retained history; * every live span receives an end frame; * replay is unlimited or time-guaranteed; * delivery is exactly once; * the Dashboard displays every retained frame or raw attribute; * all readers preserve an unknown top-level frame kind; * zero cost proves finalized free usage; or * the access-expiration timestamp is a physical-deletion deadline. ## Next steps > [card] [Live telemetry](/telemetry/live) > > Open the stream and follow a session. > [card] [Retrieve telemetry](/telemetry/retrieve) > > Refill from the retained record and page to completion. > [card] [Frame reference](/telemetry/frames) > > Build a version-aware frame decoder. > [card] [Data controls](/telemetry/data-controls) > > Control capture and protect sensitive Telemetry data. # Retrieve telemetry Source: https://docs.axilio.ai/telemetry/retrieve Retrieve the available retained spans and logs for a session through the Dashboard, CLI, SDKs, or REST API. Retained telemetry is the session record available for post-session review and live-gap reconciliation. It contains completed span frames and point-in-time log frames, addressed by `session_id`. The retained path is best-effort. Treat it as the available record, not an exactly-once audit ledger or a promise that every live frame was stored. ## Retrieve a session trace > [tab] Dashboard > > 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). > 2. Select an active or completed session. > 3. Click **Observability**. > 4. Use **Timeline** for operations and **Console** for output and errors. > > The Dashboard reconciles live telemetry with retained history when a > session completes. It currently loads at most the first 1,000 retained > frames. Use the CLI, an SDK, or paginated REST when you need every > available page from a large trace. > [tab] CLI > > Read the complete available trace in a table: > > ```bash theme={null} > axilio sessions trace sess_123 > ``` > > Return one merged JSON response after the CLI follows every retained page: > > ```bash theme={null} > axilio sessions trace sess_123 -o json > ``` > > Add `--follow` when the session is still active. Follow mode emits a > retained prefix, attaches to [live telemetry](/telemetry/live), and > reconciles the two paths. > [tab] Python SDK > > The high-level helper retrieves all pages and joins the response-level cost > maps to spans: > > ```python theme={null} > from axilio.platform import Client > > client = Client() > telemetry = client.telemetry("sess_123") > trace = telemetry.trace() > > for item in trace.spans: > print( > item.frame.span_type, > item.frame.name, > item.duration_ms, > item.billed_cost_microdollars, > ) > > for entry in trace.logs: > print(entry.severity, entry.body) > ``` > > Use `telemetry.summary()` for a derived summary and `telemetry.logs()` for > retained logs only. The generated sync and async clients also expose > `sessions_list_frames(session_id, limit=..., offset=...)` for raw pages. > > > [!WARNING] > > > > Python SDK `v0.19.0` can reject a retention-expired response because the > > runtime returns null cost maps. Use the raw REST response or another > > compatible reader when you need to inspect that state. Recheck this note > > when you update the SDK. > > Archive HTTP failures surface the generated `ApiError`. A malformed > successful body can surface `ParsingError`. Configure per-request REST > timeout or retry behavior through `request_options` or client > configuration rather than the high-level Telemetry helper. > [tab] Go SDK > > `Trace` retrieves all pages and builds a high-level trace: > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "log" > "os" > > "github.com/axilioai/platform-go/client" > "github.com/axilioai/platform-go/drivers/telemetry" > "github.com/axilioai/platform-go/option" > ) > > func main() { > ctx := context.Background() > api := client.NewClient( > option.WithAPIKey(os.Getenv("AXILIO_API_KEY")), > option.WithBaseURL("https://api.axilio.ai/api/v1"), > ) > view := telemetry.NewSession(api, "sess_123") > > trace, err := view.Trace(ctx) > if err != nil { > log.Fatal(err) > } > if trace.RetentionExpired { > fmt.Println("The retained trace is outside the access window") > } > > for _, item := range trace.Spans { > fmt.Println( > item.Span.SpanType, > item.Span.Name, > item.Duration(), > item.BilledMicrodollars, > ) > } > for _, entry := range trace.Logs { > fmt.Println(entry.Severity, entry.Body) > } > } > ``` > > Import the helper from > `github.com/axilioai/platform-go/drivers/telemetry`. Use the generated > `api.Runs.SessionsListFrames(...)` operation when you need raw pages. Use > `view.Summary(ctx)` for a derived summary and `view.Logs(ctx, false)` for > retained logs; pass `true` to tail live logs. Use `view.Tail(ctx)` to follow > every live frame kind; see [Live telemetry](/telemetry/live). > [tab] REST API > > Request one page from the canonical retained endpoint: > > REST API reference: > > * [**List a session's telemetry frames**](/api-reference/rest/runs/list-a-sessions-telemetry-frames): `GET /phones/sessions/{session_id}/frames` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --request GET \ > --url 'https://api.axilio.ai/api/v1/phones/sessions/sess_123/frames?limit=1000&offset=0' \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" \ > | jq > ``` > > The endpoint accepts `limit` from 1 through 1,000 and a nonnegative > `offset`. If omitted, `limit` defaults to 100 and `offset` defaults to 0. > Use a customer API key with viewer access to the session's organization. > [!NOTE] > > Use `/frames` for current integrations. The retired `/events` route and its > older event shapes are not a compatibility interface. ## Paginate the complete available result Read `total`, `limit`, and `offset` from every response. After processing a page, add the number of returned frames to your offset. Stop when the new offset reaches `total` or when a page returns no frames. Do not stop only because a page contains fewer items than the requested limit. The CLI and high-level Python and Go helpers handle pagination for you. Frames are ordered by ascending timeline time: `start_time_unix_nano` for a span and `time_unix_nano` for a log. Do not rely on a particular order between span and log frames with exactly equal timestamps. There is no server-side frame-kind or telemetry-type filter. Retrieve pages, then filter by `kind`, `span_type`, or `log_type` in your application. ## Interpret the response The response contains these seven top-level properties: | Field | Meaning | | - | - | | `frames` | This page of span and log frames. The current runtime returns an array. | | `total` | Total frame count available for the session. | | `limit` | Page size applied to this response. | | `offset` | Starting offset applied to this response. | | `sdk_call_costs` | Point-in-time billed microdollars keyed by SDK-call `span_id`. | | `inference_costs` | Point-in-time billed microdollars keyed by inference ID. | | `retention_expired` | Whether the access window has expired and frames were withheld. | The published schema also permits `frames` to be null even though the current runtime returns arrays. If your generated client exposes that possibility, normalize null to an empty collection before iteration and use `retention_expired` to interpret the result. ### Distinguish empty and expired results | Result | `retention_expired` | Current raw frame and cost-map shape | Interpretation | | - | - | - | - | | Available but empty | `false` | `frames: []`; cost maps are objects | No retained frames were returned. Check whether telemetry was disabled or no data was produced. | | Access expired | `true` | `frames: []`; both cost maps are `null` | Retained telemetry is outside the configured access window. | | Request fails | No successful response | An error response, not an empty trace | Fix authorization or input, or retry a transient failure. Do not convert an error into an empty or expired result. | `retention_expired` is an access decision. It does not prove that every physical copy was deleted at that instant. See [Data controls](/telemetry/data-controls) for retention and deletion boundaries. ### Interpret cost maps Cost maps belong to the response, not to individual immutable frames. Join an SDK-call cost through its `span_id`. Join an inference cost through the `axilio.inference.id` attribute on the inference span. `1,000,000` microdollars (`µ$`) equals `$1.00`. Cost joins are point-in-time and best-effort. A missing key, an empty or null map, and a present numeric zero have different possible causes. Do not interpret zero alone as proof that an operation was free or that billing is final. Use [Usage](/usage/overview) and your finalized billing records for organization-wide reconciliation. ## Recover after a live gap A live frame dropped at publish never reaches the stream. If you suspect a gap: 1. Retrieve every available `/frames` page for the session. 2. Rebuild or reconcile your local trace by frame identity and timeline time. 3. If the session is still active, reconnect to [live telemetry](/telemetry/live) with your last cursor as `Last-Event-ID`. 4. Continue to tolerate replayed frames after reconnect. ## Next steps > [card] [Frame reference](/telemetry/frames) > > Interpret frame fields, types, relationships, and attributes. > [card] [Live telemetry](/telemetry/live) > > Follow a session and resume from an opaque cursor. > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Diagnose timing, output, failures, and cost annotations. > [card] [Reliability and troubleshooting](/telemetry/reliability) > > Diagnose empty, interrupted, incompatible, or expired telemetry. # Telemetry dashboard Source: https://docs.axilio.ai/telemetry/understand-a-trace Interpret session operations, relationships, timing, output, errors, and available cost context. A Telemetry trace helps you reconstruct what a session did, where it spent time, and where it failed. Start with the Dashboard for a visual explanation. Use CLI, SDK, or REST retrieval when you need every available frame or raw attributes. ## Open the trace 1. Open [**Sessions**](https://app.axilio.ai/dashboard/axilio/sessions). 2. Select an active or completed session. 3. Open **Observability**. 4. Use **Timeline** for operations and **Console** for point-in-time logs. When a live session ends, the Dashboard reconciles the live view with its retained history. If a recording is available, its playhead can synchronize with Telemetry time. ## Read the Timeline Each bar represents an operation span. Its placement shows when the operation started and how long it ran. Parent and child spans show which operation caused another operation. ```text theme={null} session └── run ├── sdk_call │ └── inference ├── file_push └── media_capture ``` The current producer vocabulary includes session, run, SDK-call, inference, file-push, and media-capture spans. These roles are open-ended. Older retained session roots can use `phone_session`, and future Telemetry can add new values. The raw relationship uses three identifiers: | Field | Purpose | | - | - | | `trace_id` | Correlates frames in the session's product trace. | | `span_id` | Identifies one operation span. | | `parent_span_id` | Links an operation to the span that contains or caused it. A root omits it. | Completed run spans also preserve the agent and user context needed to identify where a run came from. Retained Telemetry sorts spans by `start_time_unix_nano` and logs by `time_unix_nano` on one ascending timeline. Do not depend on a stable order between frames with exactly equal timestamps. ### Filter the timeline The Timeline shows filters when relevant values exist: * **Kind** can show **SDK calls**, **Inference**, and **Gaps**. * **Op** contains the SDK operation labels present in the trace. These filters change the visible rows. They do not recalculate the totals in the header. File-push rows can appear in the Timeline, but file pushes are not a **Kind** option today. ### Use the header as a summary The header can show: * total session duration; * summed SDK-call duration; * unobserved duration; * SDK-call count; and * model cost and billable-call count when the current positive values are available. An unobserved gap marks time not covered by a recorded SDK call. It may include customer code, waiting or idle time, Axilio session activity, or telemetry that did not reach the viewer. The gap alone does not identify the cause. ## Inspect operation details Select a timeline row to inspect its available details. | Row | Current Dashboard details | | - | - | | **All operations** | Status, duration, and start offset. | | **SDK call** | Operation, cost when available, OCR engine, and error. | | **Inference** | Model, cost when available, inference ID, and stage timing. | | **File push** | File name, size, and error. | | **Unobserved gap** | An explanation of the uncovered interval. | Status helps distinguish successful and failed completed spans. In raw Telemetry, a status contains `code` and `message`; current producers use `ok` and `error`. A live start frame can arrive before end time or final status is known. The Dashboard intentionally renders a curated set of details. Raw frames can contain additional producer attributes. Captured SDK-call input and output attributes are not currently wired into the Dashboard details pane. Use [Retrieve Telemetry](/telemetry/retrieve) and the [Frame reference](/telemetry/frames) when you need the raw record. > [!WARNING] > > Raw attributes can contain customer-provided SDK inputs and outputs. Treat > them as sensitive data. Typed text receives a specific input-redaction rule, > but other nonempty inputs and results can be captured. ## Read the Console The Console presents logs on the same session timeline. Use it to find: * run output; * error output; * kernel-status messages; and * transfer progress for file pushes and media captures. A log can belong to a containing span through `span_id`, or it can be session-level. The known raw roles include `output_log`, `output_error`, `kernel_status`, and `transfer_progress`, but new `log_type` values are additive. When a run fails, inspect its session's Console and Timeline. Run output and error details belong in Telemetry rather than a `logs` field on the run response. ## Interpret cost context Retained Telemetry can join billed SDK-call and inference costs to the trace: * `sdk_call_costs` is keyed by SDK-call `span_id`. * `inference_costs` is keyed by inference ID. * Each value uses microdollars, where `1,000,000` microdollars equals `$1.00`. These response-level maps are point-in-time, best-effort joins. They can lag or be corrected independently of the frames. A missing key, an empty map, and a numeric zero are not interchangeable, so do not use any one of them as proof that an operation was definitively free. Use this page to understand cost in the context of one trace. Use [Usage](/usage/overview) for organization-level session and inference totals, and use [Billing](/billing/overview) for balance, plan, and invoice information. ## Know the Dashboard boundaries * The Dashboard currently requests one retained page with up to 1,000 frames. It does not follow later pages. A long session can therefore look incomplete even when more retained frames are available through REST, CLI, or SDK pagination. * Timeline filters hide rows without recalculating header totals. * The details pane shows known curated fields, not every raw attribute. * Captured SDK-call input and output are raw-only today. * Telemetry is best effort. A missing interval is not proof that no work happened during that interval. ### Recognize unavailable states The Dashboard shows **Telemetry disabled for this session** when the session started with Telemetry off. No live or retained trace exists for that session. It shows **Trace past your retention window** when retained access has expired. Daily, Weekly, and Monthly dedicated rental cadence does not choose a different Telemetry retention period. See [Data controls](/telemetry/data-controls) for the current standard access window and organization-policy exceptions. If the trace looks incomplete, retrieve all retained pages before drawing a conclusion. Then use [Reliability and troubleshooting](/telemetry/reliability) to distinguish gaps, expiration, disabled Telemetry, and client compatibility. ## Next steps > [card] [Retrieve Telemetry](/telemetry/retrieve) > > Read every available page and interpret empty or expired responses. > [card] [Frame reference](/telemetry/frames) > > Inspect span, log, status, timestamp, relationship, and attribute fields. > [card] [Data controls](/telemetry/data-controls) > > Learn what Telemetry can capture and how to protect sensitive data. > [card] [Reliability and troubleshooting](/telemetry/reliability) > > Recover from gaps and diagnose incomplete or unavailable traces. # Usage Source: https://docs.axilio.ai/usage/overview Review phone sessions, aggregate spend, and individual inference charges. Usage explains what your organization consumed. Billing explains the balance, plan, rentals, and invoices that pay for it. The public usage surface has three views: | View | What it answers | | - | - | | Metrics | How much session and inference usage occurred in a reporting window? | | Sessions | Which phone sessions contributed time and cost? | | Inferences | Which vision calls and agent model steps contributed cost and latency? | ## Aggregate metrics `GET /usage/metrics` returns your spend as points: one per hour or day in the time zone you name, each with the total, phone-session and inference spend in microdollars (zero when nothing was billed), and the phone time (`session_seconds`) and tokens (`input_tokens`, `output_tokens`) it bought. Add `group_by` for one series per group: | `group_by` | Splits spend by | | - | - | | `product` | `session` (phone time) or `inference`. | | `agent` | The agent whose run spent it. Spend outside an agent has an empty `agent`. | | `model` | The model that served the inference. Phone time has an empty `model`. | | `api_key` | The API key that started the work. | | `schedule` | The [schedule](/agents/schedules) that started the run. | | `device_class` | What the spend drove: `phone`. | Every group splits the same total. A dedicated phone's sessions bill nothing, so they add no `session_seconds`. The window defaults to the last 7 days and may start at most a year back; hour buckets need a window of 7 days or less. Every metrics endpoint takes the same window parameters. For run counts, success rate and duration, see [Measure runs](/agents/monitor#measure-runs). > [tab] Dashboard > > Open [**Usage → Sessions**](https://app.axilio.ai/dashboard/axilio/usage/sessions) > or [**Usage → Inferences**](https://app.axilio.ai/dashboard/axilio/usage/inferences) > after choosing your organization. Each view combines an aggregate chart > with the rows for its selected date range. > [**Billing**](https://app.axilio.ai/dashboard/axilio/billing) shows total > usage spend. > [tab] CLI > > ```bash theme={null} > axilio usage metrics --from 2026-08-01 --to 2026-08-28 --granularity day > ``` > > Add `-o json` for every point as well as the total. > [tab] Python SDK > > ```python theme={null} > from datetime import datetime, timezone > > start = datetime(2026, 8, 1, tzinfo=timezone.utc) > end = datetime(2026, 8, 28, tzinfo=timezone.utc) > > metrics = client.usage.get_metrics( > period_start_at=start, > period_end_at=end, > granularity="day", > time_zone="America/Chicago", > ) > for point in metrics.points: > print(point.period_start_at, point.session_cost_microdollars, point.inference_cost_microdollars) > total = sum(point.total_cost_microdollars for point in metrics.points) > ``` > [tab] Go SDK > > ```go theme={null} > metrics, err := api.Usage.GetMetrics(ctx, &platformgo.UsageGetMetricsRequest{ > PeriodStartAt: platformgo.Time(platformgo.MustParseDateTime("2026-08-01T00:00:00Z")), > PeriodEndAt: platformgo.Time(platformgo.MustParseDateTime("2026-08-28T00:00:00Z")), > Granularity: platformgo.UsageGetMetricsRequestGranularityDay.Ptr(), > TimeZone: platformgo.String("America/Chicago"), > }) > if err != nil { > log.Fatal(err) > } > var total int64 > for _, point := range metrics.Points { > total += point.TotalCostMicrodollars > } > fmt.Println(total) > ``` > [tab] REST API > > REST API References: > > * [**Get usage metrics**](/api-reference/rest/usage/get-usage-metrics): `GET /usage/metrics` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/usage/metrics?period_start_at=2026-08-01T00%3A00%3A00Z&period_end_at=2026-08-28T00%3A00%3A00Z&granularity=day&time_zone=America%2FChicago" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ## Session usage Session usage includes lifecycle, allocation source, phone time, inference cost, and billing processing state for each phone session. > [tab] Dashboard > > Open > [**Usage → Sessions**](https://app.axilio.ai/dashboard/axilio/usage/sessions), > then filter by session status, billing state, allocation source, or date. > Search can match a session, agent, or phone ID. > [tab] Python SDK > > ```python theme={null} > sessions = client.usage.list_sessions( > allocated_after=start, > allocated_before=end, > limit=50, > ) > for session in sessions.sessions or []: > print(session.session_id, session.total_cost_microdollars) > ``` > [tab] Go SDK > > ```go theme={null} > sessions, err := api.Usage.ListSessions(ctx, &platformgo.UsageListSessionsRequest{ > AllocatedAfter: platformgo.MustParseDateTime("2026-08-01T00:00:00Z"), > AllocatedBefore: platformgo.MustParseDateTime("2026-08-28T00:00:00Z"), > Limit: platformgo.Int64(50), > }) > ``` > [tab] REST API > > REST API References: > > * [**List phone sessions with cost**](/api-reference/rest/usage/list-phone-sessions-with-cost): `GET /usage/sessions` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/usage/sessions?allocated_after=2026-08-01T00%3A00%3A00Z&allocated_before=2026-08-28T00%3A00%3A00Z&limit=50" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` ### Filter, order, and page session usage `allocated_after` and `allocated_before` are required RFC 3339 timestamps: the response covers the sessions allocated in that window. A session-usage request also supports: * `session_status` (`active`, `completed`, `cancelled` or `expired`; repeatable); * `processed_status` (`pending`, `billed` or `failed`; repeatable); * `agent_id`; * `allocated_by` (`dashboard_run`, `api_run` or `interactive`; repeatable); * `search`; * `limit` from 1 through 100, defaulting to 50; and * a nonnegative `offset`, defaulting to 0. Use `order_by` with one of `allocated_at`, `deallocated_at`, `duration_seconds`, `cost_microdollars`, `session_status`, `processed_status`, `allocated_by`, `session_id`, or `agent_id`, followed optionally by `asc` or `desc`. The default is `allocated_at desc`. The response contains `sessions`, `total`, `limit`, and `offset`. Continue requesting pages until you have consumed `total`. Each row identifies the session, agent, and phone and reports session, inference, and total microdollar cost. It can also include allocation and deallocation time, duration, session and processing status, billing plan, allocation source, and session metadata. An interactive session has an empty `agent_id`, because no agent's run created it. > [!WARNING] > > Read `processed_status` before interpreting cost. A numeric zero on a > pending row is not proof that the finalized session was free. Inference-cost > lookup is also best effort, so a point-in-time total can temporarily omit an > inference component. The CLI currently exposes aggregate metrics and inference rows, but not the session-usage table. Use the dashboard, an SDK, or REST for this view. ## Inference usage > [tab] Dashboard > > Open > [**Usage → Inferences**](https://app.axilio.ai/dashboard/axilio/usage/inferences). > Filter by endpoint, model, or date, and use search to find an ID. > [tab] CLI > > ```bash theme={null} > axilio usage inferences --from 2026-08-01 --to 2026-08-28 --endpoint locate --limit 50 > ``` > [tab] Python SDK > > ```python theme={null} > inferences = client.usage.list_inferences( > created_after=start, > created_before=end, > endpoint=["locate"], > limit=50, > ) > for inference in inferences.inferences or []: > print(inference.inference_id, inference.cost_microdollars) > ``` > [tab] Go SDK > > ```go theme={null} > inferences, err := api.Usage.ListInferences( > ctx, > &platformgo.UsageListInferencesRequest{ > CreatedAfter: platformgo.MustParseDateTime("2026-08-01T00:00:00Z"), > CreatedBefore: platformgo.MustParseDateTime("2026-08-28T00:00:00Z"), > Endpoint: []platformgo.UsageListInferencesRequestEndpointItem{ > platformgo.UsageListInferencesRequestEndpointItemLocate, > }, > Limit: platformgo.Int64(50), > }, > ) > ``` > [tab] REST API > > REST API References: > > * [**List inference calls**](/api-reference/rest/usage/list-inference-calls): `GET /usage/inferences` > > Examples use Bash. To run them in PowerShell, see [Run the curl examples on Windows](/api-reference/overview#run-the-curl-examples-on-windows). > > ```bash theme={null} > curl --silent --show-error --fail-with-body \ > --url "https://api.axilio.ai/api/v1/usage/inferences?created_after=2026-08-01T00%3A00%3A00Z&created_before=2026-08-28T00%3A00%3A00Z&endpoint=locate&limit=50" \ > --header "X-Axilio-Api-Key: $AXILIO_API_KEY" > ``` Each inference row names the operation it billed in `endpoint`: | `endpoint` | Operation | Billed | | - | - | - | | `detect` | [Screenshots: detect](/api-reference/argus/screenshots-detect) | Per page, on the Axilio model line | | `locate` | [Screenshots: locate](/api-reference/argus/screenshots-locate) | Per token, on the model that ran | | `element_locate` | [Elements: locate](/api-reference/argus/elements-locate) | Per token, on the model that ran | | `agent_chat` | One model step of an agent-mode [run](/agents/overview) | Per token, on the run's model | Pass any of these values in `endpoint`, which repeats to match several. Every amount is in microdollars, and its name ends in `_microdollars`. One US dollar equals 1,000,000 microdollars. ## Next steps > [card] [Telemetry dashboard](/telemetry/understand-a-trace) > > Relate point-in-time operation costs to one session's execution. > [card] [Billing](/billing/overview) > > Understand dedicated rentals, balance, and payment actions. > [card] [Balance, credits, and invoices](/billing/balance-and-invoices) > > Understand credit lifecycle, configure alerts, and download invoice PDFs. # Webhook event reference Source: https://docs.axilio.ai/webhooks/events Every webhook event Axilio sends: when it fires, what its data contains, and how to recognise a duplicate. Each event describes one change to one resource. Its `type` names the resource and what happened to it, like `run.failed` or `phone.online`. The body's `data` is a snapshot of that resource. It uses the same field names and formats as the API, but carries only the fields listed for each event: never your own content (variables, names, tags), files or download links. See [Webhooks](/webhooks/overview) for the request format, signatures and delivery guarantees. Events about a session, a run or a device you rent carry `device_class`, which says what kind of device the event concerns. It is `phone` today. > [!NOTE] > > You can send every type below from the dashboard with **Test** on a > destination's page. A test carries the example payload shown here and > `"test": true`. ## Recognising duplicates The body's `id` (and the `webhook-id` header) is the same every time one change reaches you, so you can skip an `id` you have already handled. Axilio builds it from the event type and the resource: | Events | `id` | | - | - | | `run.started`, `run.completed`, `run.failed`, `run.cancelled` | `:` for a run's first turn, and `::` for each [follow-up turn](/agents/pause-and-follow-up#follow-up-after-a-turn). | | `run.paused`, `run.resumed` | `:`. A run can pause more than once. | | `session.started`, `session.ended`, `phone.rental.activated`, `phone.rental.canceled`, `subscription.canceled` | `:`. These happen once per resource. | | `schedule.ended` | `:`. Once per schedule. | | `schedule.paused` | `::