# How to Run and Track Site Commands with the xCloud Public API

> Run a one-off command on an xCloud site from a script and read its status, exit code and output, with a token that has the commands:run permission.

You can run a one-off command on any xCloud site from a script and follow it to the end: send the command with `POST /api/v1/sites/{site}/commands`, then read its status, exit code and output with `GET /api/v1/sites/{site}/commands/{task}`. The command runs as the site's user, never as root. It replaces the old trick of adding a temporary cron job just to run something once, such as `wp cache flush` after a deploy.

Site commands are new in xCloud v2.9.7.4. They are available through the Public API only.

## What you need first

All four are checked on every request:

| Requirement | Where to set it |
|---|---|
| An API token with the `commands:run` permission | **Settings** → **Developers** → **API Tokens** (Step 1 below) |
| Two-factor authentication turned on for your account | See [Two-Factor Authentication](/docs/two-factor-authentication-set-it-up-and-recover-access-if-you-lose-your-device/) |
| The **Team Admin**, **Server Admin** or **Site Admin** role in the site's team | Your team owner sets roles |
| Permission to use the site's **Commands** page | The same permission the dashboard uses; if you see **Commands** in the site menu, you have it |

**Full Access does not include `commands:run`.** Running commands is opt-in, so a token made for reading servers or deploying sites cannot run commands unless you ticked this box. Tokens used by the in-app AI Assistant are always refused.

## Step 1: Create a token with commands:run

Go to **Settings** → **Developers** → **API Tokens** and click **Create New Token**. In **Create A New API Token**, enter a **Token Name**, untick **Full Access (all API permissions)**, then tick **commands:run**. Add any other permissions your script needs, and click **Create Token**.

![Tick 'commands:run' with 'Full Access' unticked in the Create A New API Token window](/_landing/docs/run-site-commands-with-xcloud-public-api-image-1.png)

**Expected result:** the new token is shown once. Copy it and store it somewhere safe. The token list shows `commands:run` among its permissions.

Leave at least one permission unticked. If you tick every box, the window switches back to **Full Access**, which does not grant `commands:run`. A token with only `commands:run` is the safest choice for a script that just runs commands. For more on tokens, see [How to Access the xCloud API](/docs/how-to-access-the-xcloud-api/).

## Step 2: Find the site's UUID

Commands are sent to one site, named by its UUID. List your sites and copy the `uuid` of the one you want:

```bash
export XCLOUD_TOKEN='YOUR_API_TOKEN'

curl 'https://app.xcloud.host/api/v1/sites' \
  --header "Authorization: Bearer $XCLOUD_TOKEN" \
  --header 'Accept: application/json'
```

**Expected result:** a `200` response with your sites. Store the UUID as `SITE_UUID`. To work in another team the token is granted to, add the `X-Team-Id` header to every request in this guide; see [Multi-Team API Tokens and MCP Access](/docs/multi-team-api-tokens-and-mcp-access/).

## Step 3: Send the command

Send the command in a JSON body with a unique `Idempotency-Key` header. The header is required: it makes the request safe to retry, because a repeated request never runs the command twice. Make one random key per command, for example with `uuidgen`, and keep it in a variable. If a request times out, retry it with the same key so the command is not queued twice. Make a new key only for a new command.

```bash
export SITE_UUID='3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f'
export IDEMPOTENCY_KEY=$(uuidgen)

curl -X POST "https://app.xcloud.host/api/v1/sites/$SITE_UUID/commands" \
  --header "Authorization: Bearer $XCLOUD_TOKEN" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data '{"command": "wp cache flush"}'
```

**Expected result:** `202 Command queued`, with the ID you use to follow the command and the URL to poll. Example response (the IDs are placeholders):

```json
{
  "success": true,
  "message": "Command queued",
  "data": {
    "task_uuid": "7e8f9a0b-1c2d-4e3f-8a9b-0c1d2e3f4a5b",
    "status_url": "https://app.xcloud.host/api/v1/sites/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/commands/7e8f9a0b-1c2d-4e3f-8a9b-0c1d2e3f4a5b"
  }
}
```

Copy `data.task_uuid` from the response and store it for the next step:

```bash
export TASK_UUID='7e8f9a0b-1c2d-4e3f-8a9b-0c1d2e3f4a5b'
```

You can also call `data.status_url` directly; it is the same address.

The body takes only `command`, at most 4,096 bytes. You cannot choose the user: a `user` or `site_id` field is rejected with `422`, and the command always runs as the site's user in the background.

## Step 4: Poll for the result

Call `status_url` until `status` is no longer `queued` or `running`:

```bash
curl "https://app.xcloud.host/api/v1/sites/$SITE_UUID/commands/$TASK_UUID" \
  --header "Authorization: Bearer $XCLOUD_TOKEN" \
  --header 'Accept: application/json'
```

**Expected result:** each poll returns `200`. When `status` is `finished`, `failed`, `timeout` or `killed`, the command has ended. Example response (the IDs, times and output are placeholders; yours show your command's real output):

```json
{
  "success": true,
  "message": "Success",
  "data": {
    "status": "finished",
    "exit_code": 0,
    "output": "Success: The cache was flushed.\n",
    "truncated": false,
    "started_at": "2026-10-30T12:10:04+00:00",
    "finished_at": "2026-10-30T12:10:06+00:00"
  }
}
```

| Field | Meaning |
|---|---|
| `status` | `queued`, `running`, then one of `finished`, `failed`, `timeout` or `killed` |
| `exit_code` | The command's exit code, or `null` while it has none |
| `output` | The command's output. Long output keeps only the last 60,000 bytes |
| `truncated` | `true` when the output was longer than 60,000 bytes and the start was cut |
| `started_at`, `finished_at` | When the command started and ended, or `null` |

Check `exit_code` as well as `status`: a command that ran but returned an error exits with a non-zero code. Only the person who sent a command can read it, and only while they still have access to the site; anyone else gets `404`. Responses are never cached.

A small polling loop with `jq`:

```bash
until STATUS=$(curl -sf "https://app.xcloud.host/api/v1/sites/$SITE_UUID/commands/$TASK_UUID" \
    --header "Authorization: Bearer $XCLOUD_TOKEN" --header 'Accept: application/json' \
    | jq -r '.data.status'); [ -n "$STATUS" ] && [ "$STATUS" != "null" ] \
    && [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ]; do
  sleep 5
done
echo "Command ended: $STATUS"
```

If a poll fails, for example with `429` or a network error, the loop waits and polls again instead of stopping.

## Limits

| Limit | Value |
|---|---|
| Commands sent (POST) | 30 per hour per token, and 120 per hour per team |
| Status checks (GET) | 120 per minute per token, and 120 per minute per team, counted apart from POST |
| Command length | 4,096 bytes |
| Output returned | The last 60,000 bytes |

Going over a limit returns `429`. Wait and try again; for status checks, a few seconds between polls keeps you well inside the limit.

xCloud keeps a record of every command you send and every status check, and of requests refused because the token lacks `commands:run` or two-factor authentication is off: who sent it, with which token, for which site, and when. The record does not hold the command text or its output.

## If something goes wrong

| Response | What it means and what to do |
|---|---|
| `403 command_ability_required` | The token has no `commands:run`, or it is a Full Access or AI Assistant token. Create a token as in Step 1 |
| `403 two_factor_required` | Turn on two-factor authentication for your account, then retry with the same token |
| `403` (other) or `404` on POST | Your role or permissions do not cover this site, or the site is not in the token's team. Check the role, the **Commands** permission and `X-Team-Id` |
| `404` on GET | The command was sent by someone else, belongs to another site, or you lost access to the site |
| `409` | A request with the same `Idempotency-Key` is still being accepted. Wait a moment and retry it unchanged |
| `422` | The command is missing or longer than 4,096 bytes, the `Idempotency-Key` is missing, the key was used for a different command, or the site runs as root (`command_runner_root_site_unsupported`) |
| `429` | You hit a limit from the table above. Wait and retry |

To run a command by hand instead, use the site's **Commands** page in the dashboard; it works as before. See [How to Run Commands in Your Site and Server](/docs/how-to-run-commands-in-your-site-and-server/). For every other endpoint, see the [xCloud API reference](/docs/xcloud-api/).

## Frequently asked questions

### Why does my Full Access token get command_ability_required?

Full Access does not include commands:run. Running commands is opt-in: create a token with Full Access unticked and commands:run ticked. Tokens used by the in-app AI Assistant are refused too.

### Can a command run as root?

No. Every command runs as the site's own user. A site whose user is root is refused with command_runner_root_site_unsupported.

### What happens if my script sends the same request twice?

The command runs once. A repeat with the same Idempotency-Key and the same command returns the first answer. The same key with a different command returns 422, and a repeat that arrives while the first is still being accepted returns 409.

### Can I run site commands through xCloud MCP?

Not yet. In this release only the Public API offers these two operations. MCP clients and the in-app AI Assistant cannot run them, and the Commands page in the dashboard works as before.

If you run into any issues, feel free to reach out to our [support team](/docs/access-built-in-support-portal-in-xcloud/).
