# Belov Cloud — REST API, MCP server & CLI

Everything an agent or a script needs to work on sites hosted on Belov Cloud — WordPress sites and
Node/Next.js apps. Three front doors to the same operations, with the same rules:

| | Where | Auth |
|---|---|---|
| REST API | `https://belov.cloud/api/v1` | account token `bc_live_…` |
| MCP server | `https://belov.cloud/api/mcp` (Streamable HTTP) | OAuth (claude.ai, ChatGPT, Claude Code, Cursor) or an account token |
| CLI | `npx -y belov <command>` (npm package `belov`) | account token |

For work inside a site — a shell, WP-CLI, file transfer, a database tunnel — use **SSH**: every
WordPress site and every full-stack app has it (`GET /api/v1/sites/{id}/ssh` returns the config).

---

## Authentication

### Account tokens (`bc_live_…`)

Created by an owner or admin under **Account → API tokens** (`https://belov.cloud/account#agents`).
Sent as a bearer header:

```
Authorization: Bearer bc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- **Reach:** every site on the account.
- **Access:** *full* (read and act) or *read-only* (list and inspect; no changes, no secret values,
  no backup downloads).
- **Expiry:** never, 30 days, 90 days or a year.
- **Tied to its creator:** a token works only while the person who created it is still an owner or
  admin of the account. Removing or demoting them stops their tokens immediately.
- Shown once; only a SHA-256 hash is stored. Revoke any time in the panel.

### OAuth 2.1 (MCP)

The MCP server is an OAuth-protected resource. AI apps discover the flow from its `401`
(`WWW-Authenticate: Bearer resource_metadata=…`), register themselves (RFC 7591), and send the
person to approve. The resulting token acts as **that person** — their role and site scope apply,
so a developer limited to two sites connects an agent that sees those two sites.

| | |
|---|---|
| Protected resource metadata | `/.well-known/oauth-protected-resource/api/mcp` |
| Authorization server metadata | `/.well-known/oauth-authorization-server` |
| Registration | `POST /oauth/register` |
| Authorization | `GET /oauth/authorize` (code + PKCE S256 only) |
| Token | `POST /oauth/token` (authorization_code, refresh_token — rotated on use) |
| Revocation | `POST /oauth/revoke` |
| Scopes | `sites:read`, `sites:write` (the person can narrow to read-only on the consent screen) |

Access tokens last an hour; refresh tokens 60 days. Connected apps are listed — and disconnected —
under **Account → AI agents**.

### Limits

120 requests a minute per token or connection. Failed authentication is limited per IP.

---

## Conventions

- JSON in and out. Errors are `{"error": "…"}` with a meaningful status:
  `400` bad input · `401` no valid credential · `403` read-only token, suspended account or paused
  site · `404` not found **or outside your access** (existence isn't revealed) · `409` conflict
  (a deploy already running, no staging yet…) · `429` rate limited · `502` the server couldn't be reached.
- **Long work** answers `202` with something to follow:
  - deploys and rollbacks → a release number: `GET /sites/{id}/deploys/{seq}?wait=45`
  - everything else (PHP switch, staging, restores, backups, env apply) → an **operation**:
    `GET /operations/{id}?wait=45`
  - `?wait=N` (max 50) holds the request until the work finishes or N seconds pass, so you can
    loop on it without sleeping.
- **Destructive calls take an explicit confirmation** in the body: `{"confirm": "PUSH"}` to push
  staging to live, `{"confirm": "RESTORE"}` to replace a live database.
- Every change is recorded in the site's activity feed, attributed to the token (or the person and app).

---

## Endpoints

### Account

| Method | Path | |
|---|---|---|
| GET | `/whoami` | The account and what this token may do |
| GET | `/sites` | Sites and apps you can reach: `id, name, domain, type, status, live, url` (+ `preset` for apps, `php` for WordPress) |
| GET | `/activity?limit=30` | Activity feed, newest first (max 100) |
| GET | `/operations/{id}?wait=45` | Follow an operation |

### Any site

| Method | Path | |
|---|---|---|
| GET | `/sites/{id}` | Details: URLs, versions, staging, deploy summary, and an `ssh` block |
| GET | `/sites/{id}/ssh` | SSH: `available`, `config` (a `~/.ssh/config` block), `hostKeyFingerprint`, examples — or `reason` |
| GET | `/sites/{id}/activity?limit=30` | The site's activity |
| GET | `/sites/{id}/logs?lines=200` | Recent runtime output (max 1000 lines); `&staging=1` for a WordPress staging copy |
| POST | `/sites/{id}/restart` | Restart the runtime |
| GET | `/sites/{id}/backups` | Restore points, newest first |
| POST | `/sites/{id}/backups` | Take a backup now → operation |
| POST | `/sites/{id}/backups/link` | `{"file"}` → a short-lived download URL (full-access tokens) |

### Node / Next.js apps

| Method | Path | |
|---|---|---|
| GET | `/sites/{id}/deploys` | Recent deploys with status and the failure reason (`error`) |
| POST | `/sites/{id}/deploys` | Deploy the branch HEAD → `{deployment: {seq}}` |
| GET | `/sites/{id}/deploys/{seq}?wait=45` | One deploy + build-log tail; with `wait`, until live or failed |
| POST | `/sites/{id}/deploys/{seq}/rollback` | Back to that release (one of the newest 3 kept) |
| GET | `/sites/{id}/env` | `{env, managed}` — values need a full-access token |
| PATCH | `/sites/{id}/env` | `{"set": {"K": "v"}, "unset": ["K"], "apply": true}` — merge |
| PUT | `/sites/{id}/env` | `{"env": {"K": "v"}, "apply": true}` — replace |
| GET | `/sites/{id}/schedules` | Scheduled jobs (cron, UTC) with `lastRunAt` / `lastExit`; `inSync: false` = the server hasn't taken the latest list yet |
| POST | `/sites/{id}/schedules` | `{"schedule": "*/15 * * * *", "command": "npm run scan"}` — add one (the same schedule + command returns the existing job) |
| PUT | `/sites/{id}/schedules` | `{"jobs": [{"schedule", "command", "id"?}]}` — replace the whole list (`[]` removes all) |
| DELETE | `/sites/{id}/schedules/{job}` | Remove one job |
| GET | `/sites/{id}/schedules/{job}/log?lines=200` | Output of the job's last run |

Env changes go live the way the panel's *Save & apply* does: a health-checked restart, or a rebuild
when a Next.js `NEXT_PUBLIC_*` value changed (`status: "rebuilding"` + the new release). Platform
values (`PORT`, `DATABASE_URL`, `REDIS_URL`, attached databases) are skipped with a reason.
`apply: false` saves them for the next deploy.

Scheduled jobs are the panel's Schedule tab: standard 5-field cron in UTC (`@daily`-style macros
too), a one-line command run with `sh` inside the app's container, up to 50 per app. A job never
overlaps itself; when many jobs match the same minute they start in turn (8 at a time per app), late
rather than skipped.

### Belov Git

The account's own private Git hosting at `git.belov.cloud` (people sign in with their Belov Cloud
account; access follows their team role). A push to an app's branch deploys it.

| Method | Path | |
|---|---|---|
| GET | `/git` | On or off, organization, clone address pattern, the plan's backup schedule |
| POST | `/git` | `{"org": "acme"}` — turn it on (owners/admins); the name is in every clone URL and can't change |
| GET | `/git/repos` | Repositories (scoped developers: only their apps' repos) with clone addresses, apps, last backup |
| POST | `/git/repos` | `{"name": "site", "description": "…"}` — an empty private repository |
| POST | `/git/repos/import` | `{"source": "https://github.com/you/project", "name"?, "token"?}` — every branch, tag and the history; with a token also issues, PRs and releases. The token is used once, never stored |
| GET | `/git/repos/{repo}/branches` | Branches |
| GET | `/git/backups?repo=` | Repository backups (git bundles) on the plan's schedule — weekly on Front, daily on Pro/Full-stack, daily with 30 days on Scale |
| POST | `/git/backups/{id}/link` | A 15-minute download link (full-access tokens) — restore with `git clone file.bundle` |
| POST | `/sites/{id}/git` | `{"repo": "site", "branch"?: "main"}` — the app deploys from that repo (owners/admins); `POST /sites/{id}/deploys` builds it now |

### WordPress sites

| Method | Path | |
|---|---|---|
| POST | `/sites/{id}/wp` | `{"command": "plugin list --status=active", "staging": false}` — WP-CLI (see below) |
| POST | `/sites/{id}/cache/purge` | Clear the whole cache (page + object) |
| POST | `/sites/{id}/cache/purge-url` | `{"url": "/pricing"}` — one page (a path or a full URL on the site) |
| POST | `/sites/{id}/php` | `{"version": "8.4"}` → operation (health-checked, rolls back on failure) |
| POST | `/sites/{id}/staging` | Create staging → operation; its result has `url`, `user`, `password` (shown once) |
| POST | `/sites/{id}/staging/pull` | Refresh staging from live → operation |
| POST | `/sites/{id}/staging/push` | `{"confirm": "PUSH", "mode": "everything"\|"files"\|"db", "force": false}` → operation |
| DELETE | `/sites/{id}/staging` | Delete staging |
| POST | `/sites/{id}/staging/restore` | `{"file"}` — restore a backup **into staging** (never touches live) → operation |
| POST | `/sites/{id}/backups/restore` | `{"file", "confirm": "RESTORE"}` — restore the **live site** (database + wp-content) from a backup; a restore point is taken first or nothing changes → operation |

**WP-CLI** runs as the site's web user, on live or (`"staging": true`) on staging. The answer is
`{exitCode, stdout, stderr, truncated}`; a command still running after ~25 s becomes an operation
whose result is that output. Commands are allowlisted:

- *read* (any access): `plugin list`, `theme list`, `core version`, `cron event list`, `post list`, `db size`…
- *secret reads* (full access): `option get`, `config get`, `user list`, `search-replace … --dry-run`…
- *changes* (full access): plugin/theme install (wordpress.org slugs), activate, update, delete; `core
  update`; `cache flush`; `search-replace`; options, users, posts, menus, cron…
- *refused* — use SSH: `eval`, `shell`, `package`, `--exec`, `--require`, raw SQL (`db query`,
  `db import/export`), `config set`, installs from a URL or zip.

A staging push ends in `needs_confirmation` (with `ahead`, the number of newer live changes) when
live changed after staging was made; ask, then push again with `"force": true`.

### Operations

```json
{"operation": {"id": "op_…", "kind": "php", "siteId": "…", "status": "running", "createdAt": "…"}}
```

`status`: `running` → `done` | `failed` | `needs_confirmation`. `result` carries what the work
produced; `error` says why it failed. Outcomes are recorded even if nobody polls.

---

## MCP server

`https://belov.cloud/api/mcp` — Streamable HTTP, stateless, protocol `2025-11-25` (and earlier).

**Connect**

- **claude.ai / Claude Desktop:** Settings → Connectors → Add custom connector → paste the URL.
- **ChatGPT:** add a custom MCP connector (developer mode) with the URL.
- **Claude Code:** `claude mcp add --transport http belov https://belov.cloud/api/mcp` — signs in on first use.
- **Cursor / VS Code:** `{"mcpServers": {"belov": {"url": "https://belov.cloud/api/mcp"}}}`.
- **Header instead of OAuth:** `--header "Authorization: Bearer bc_live_…"`.

**Tools** (read-only connections see only the read tools; destructive ones are annotated so
clients ask before running them):

`whoami` · `list_sites` · `get_site` · `get_ssh_info` · `get_activity` · `get_logs` · `list_deploys` ·
`get_deploy` (+ `wait_seconds`) · `deploy` · `rollback` · `restart` · `get_env` · `set_env` ·
`list_schedules` · `add_schedule` · `remove_schedule` · `set_schedules` · `get_schedule_log` ·
`purge_cache` · `purge_url` · `switch_php` · `wp_cli_read` · `wp_cli` · `staging_create` · `staging_refresh` · `staging_push` ·
`staging_delete` · `list_backups` · `create_backup` · `backup_download_link` ·
`restore_backup_to_staging` · `restore_database` · `get_operation` (+ `wait_seconds`) ·
`git_status` · `enable_git` · `list_git_repos` · `list_git_branches` · `create_git_repo` · `import_git_repo` ·
`connect_app_to_git` · `list_git_backups` · `git_backup_download_link`

---

## CLI

```
npx -y belov sites                                       # no install
npm i -g belov                                           # or keep it
curl -fsSL https://belov.cloud/cli/install.sh | sh       # no npm registry? straight from us
export BELOV_TOKEN=bc_live_…                             # or ~/.belov/config.json {"token": "…"}
```

```
belov sites · site <id> · activity [<id>] · whoami
belov deploy <id> --wait · deploy-status <id> <seq> --wait · rollback <id> <seq> · deploys <id>
belov env <id> · env:set <id> K=V… · env:unset <id> K… · env:push <id> < .env [--replace]
belov schedules <id> · schedule:add <id> "*/15 * * * *" npm run scan · schedule:rm <id> <job> · schedule:log <id> <job>
belov logs <id> · restart <id> · ssh-config <id> >> ~/.ssh/config
belov wp <id> [--staging] plugin list --status=active
belov cache:purge <id> [/path] · php <id> 8.4 --wait
belov staging:create|refresh <id> --wait · staging:push <id> --confirm --wait · staging:delete <id>
belov backups <id> · backup <id> --wait · backup:link <id> <file> · backup:restore <id> <file> --confirm
belov git · git:repos · git:create <name> · git:import <https-url> · git:branches <repo>
belov git:connect <app-id> <repo> [--branch B] · git:backups [<repo>] · git:backup-link <backup-id>
belov op <operation-id> --wait
```

Every command takes `--json`. Exit code is non-zero on errors and on a failed `--wait`.

---

## Security

- Least data: responses never include node internals or credentials beyond what the call is for
  (env values and backup links need full access).
- Scope: every lookup is bound to the caller's account and site scope; anything else is `404`.
- Guards: a suspended account or paused site can't be changed through the API, as in the panel.
- Attribution: activity entries name the token (`API token "CI"`) or the person and app
  (`ana@example.com via Claude`).
