# Remotes API

> Register machines that connect as remotes, inspect their status, and dispatch command executions with streamed output.

**URL:** https://inference.sh/docs/api/rest/remotes
**Last updated:** 2026-10-31

---

Register machines that connect as remotes, inspect their status, and dispatch command executions with streamed output.

Requires API key scopes **`remotes:read`** (list, get, read exec output) and **`remotes:write`** (register, update, delete, heartbeat, WebSocket connect, dispatch execs, signal runs). Launching a harness chat also needs **`agents:execute`**.

**Who can do what.** A remote is its owner's machine, and whatever reaches it runs as the owner's account. Teammates see it through its visibility. Beyond that it is the owner's unless they [share it](/docs/api/rest/overview#visibility-and-sharing):

| Action | Who |
|--------|-----|
| See it in listings and get it | Anyone its visibility admits |
| Operate it: dispatch and signal execs, launch and list harness sessions, place agents on it | The owner, or a `use` or `write` share |
| Manage it: update, rename, [update belt](#update-belt-on-a-remote), policy rules, attachments, delete, shares | The owner, or a `write` share |
| Heartbeat and the daemon WebSocket | The owner only |

A caller who can read the remote but lacks the grant gets `403` ("this remote is another person's machine and is not shared with you for this"). Anyone else gets `404`. Workspace and org admins do not bypass use or write. [Set remote tags](#set-remote-tags) is the exception: it is gated by `manage_policy`.

→ [Remotes guide](/docs/extend/remote) · [CLI setup](/docs/extend/cli-setup#remotes-cli)

---

## Concepts

| Resource | Description |
|----------|-------------|
| **Remote** | A machine running `belt remote start`. It hosts agent harness profiles and optional shell or harness-session execution. |
| **Profile** | One harness slot on a remote (for example Claude Code). Profiles are created from daemon registration. There is no REST endpoint that creates them. |
| **Exec run** | One command executed on a remote, with sequenced stdout/stderr output stored for replay |

The daemon registers with `POST /remotes`, then keeps an outbound WebSocket open at `wss://api.inference.sh/ws/remotes/{id}` (or your configured API host). The API treats that connection as the source of truth for `status`; heartbeats only update `heartbeat_at`. Command output arrives over that socket as `remote_exec_output` frames and is stored per run.

**Exec opt-in.** `exec_enabled` on the remote reflects whether the daemon was started with `belt remote start --exec`. The API refuses dispatch when it is `false`, and the daemon refuses the command frame as well.

**Agent sessions opt-in.** Registration may include `agents_enabled: true` when the daemon was started with `belt remote start --agents`. The daemon enforces this flag: without it, harness session frames are refused on the host even if the API sends them.

Connecting a machine only registers harness **profiles** on the remote — it does not create workspace agents. The first time you [launch a harness chat](#launch-harness-chat) for a profile, the API creates one agent for that profile (for example title `Claude Code · work-laptop`) and reuses it on later launches. Run behavior is documented under [Remote harness runs](/docs/api/rest/agents#remote-harness-runs).

---

## Register remote

`POST /remotes`

Called by `belt remote start` on startup. Registration keys on the daemon's `public_key` for your user **and active team** (from the API key or `X-Team-ID`). A machine that registers again with the same key in the **same team** **reuses its existing remote** and does not create a duplicate. Registering after a team switch creates a new remote for that team; older team rows age out through the heartbeat staleness sweep (`disconnected`, then `stopped`). Each registration also reconciles the `harnesses` probe results into **profiles** (one per harness kind) and returns the remote with `profiles` populated.

### Request

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Display name, sent only with `--name`. Empty keeps the remote's current name; a new remote is named after `system_info.hostname`. |
| `public_key` | string | Base64 ed25519 public key that identifies the machine |
| `remote_version` | string | belt version string |
| `system_info` | object | Host metadata (see below) |
| `exec_enabled` | boolean | Whether this daemon accepts command execution (`belt remote start --exec`) |
| `agents_enabled` | boolean | Whether this daemon hosts harness sessions (`belt remote start --agents`) |
| `home` | string | Optional. Absolute POSIX home directory of the OS user the daemon runs as (for example `/home/you` or `/Users/you`). The API stores it for workspace policy: paths written as `~/…` in rules or in remote exec/harness calls resolve against this directory on that machine. Windows-style or relative values are ignored. Not returned on `RemoteDTO`; re-registration replaces the stored value. Current belt daemons send it on each register. |
| `harnesses` | array | Harnesses discovered on the host (see below) |

The handler does not validate any of these fields as required.

#### `system_info`

`belt remote` sends a subset of the API's `SystemInfo` object (same JSON shape [engines](/docs/api/rest/engines) use for host telemetry). Remotes omit engine-only fields such as `gpus`, `docker`, `engine_version`, and `hf_cache`. Omitted fields default to zero values server-side.

| Field | Type | Description |
|-------|------|-------------|
| `hostname` | string | Machine hostname |
| `os` | string | OS label, for example `Ubuntu 24.04 LTS` or `macOS` |
| `cpus` | array | One aggregate entry with `name`, `cores`, optional `frequency`, and `usage` (0–100, all cores) |
| `ram` | object | `total` and `used` in bytes; `usage` is used memory as a percentage (0–100) |
| `volumes` | array | Optional. Today one entry: the filesystem that holds the user's home directory (`name` is the home path; `size`, `used`, `free`, and `usage` in bytes / percent) |

```json
{
  "hostname": "work-laptop",
  "os": "macOS",
  "cpus": [{ "name": "Apple M3 Pro", "cores": 12, "usage": 14.2 }],
  "ram": { "total": 38654705664, "used": 19327352832, "usage": 50.0 },
  "volumes": [
    { "name": "/Users/you", "size": 994662584320, "used": 512000000000, "free": 482662584320, "usage": 51.5 }
  ]
}
```

Registration includes a full snapshot with the usage fields above. While the daemon stays connected, it attaches a fresh snapshot to every **third** WebSocket `remote_heartbeat` frame (~**15 seconds** at the default **5 second** heartbeat interval). The first heartbeat after connect omits `system_info` because registration already sent one; CPU `usage` on later snapshots reflects load since the previous collection. The API stores each heartbeat payload on the remote row, so `GET /remotes/{id}` can show live utilization without re-registering.

The HTTP `POST /remotes/{id}/heartbeat` endpoint records liveness only; it does not accept `system_info`.

#### `harnesses` entries

Each object describes one agent harness binary found on `PATH`:

| Field | Type | Description |
|-------|------|-------------|
| `kind` | string | Harness registry id (for example `claude`, `codex`, `gemini`, `cursor`) |
| `command` | string | Resolved path to the harness binary |
| `version` | string | Harness version from discovery. Optional, best effort. |
| `logged_in` | boolean | `true` when the harness looks configured on the host, or when a usage probe reports `authenticated`; `false` when the probe reports `unauthenticated`. The daemon never reads credential files. |
| `capabilities` | object | Session capabilities from the daemon's session driver for this harness (see [Profile](#profile)). Omitted when the harness cannot be driven from inference. |
| `account` | object | Who the harness is signed in as (see [Profile `account`](#profile-account)). Omitted until a usage probe has run; registration does not wait for one. |
| `limits` | object | Plan rate-limit windows (see [Profile `limits`](#profile-limits)). Omitted until a usage probe has run. |

A harness kind that is missing from the array is not installed, or has no resolvable binary on `PATH`.

#### Profile reconciliation

On each registration, and on each `remote_heartbeat` that carries `harnesses`, the API reconciles `harnesses` into the remote's `profiles`:

| Input | Profile effect |
|-------|----------------|
| New `kind` | Creates a profile with `harness_kind`, `command`, and `status` |
| Existing `kind` | Updates `command`, `status`, and `capabilities` in place (same profile `id`) |
| `logged_in: true` | `status` becomes `idle` |
| `logged_in: false` | `status` becomes `unavailable` |
| Harness no longer reported | The profile is marked `unavailable`. It is not deleted. |
| `capabilities` omitted | Keeps the profile's previous capabilities (older daemons) |
| `account` or `limits` present | Stored on the profile. A probe that failed (`limits.unavailable.reason: probe_failed`) keeps the last good windows. |

A connected daemon started with `--agents` rediscovers its harnesses, probes each one's account and plan limits (in parallel, 60 second timeout each) and sends the result on a heartbeat right after it connects and then every 5 minutes.

The API never receives harness credentials.

### Response

`RemoteDTO`:

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Remote ID |
| `name` | string | Display name |
| `status` | string | Lifecycle status (see below) |
| `heartbeat_at` | string \| null | Time of the last heartbeat |
| `system_info` | object \| null | Host metadata from the daemon (registration and optional WebSocket heartbeat updates) |
| `remote_version` | string | belt version the daemon reported |
| `exec_enabled` | boolean | Whether the host opted in to command execution |
| `agents_enabled` | boolean | Whether the host opted in to harness agent sessions |
| `tags` | string[] | Policy grouping labels. A workspace rule selector `tag:<name>` applies to every remote that carries `<name>`. Omitted or empty when unset. |
| `profiles` | array | Harness profiles (see below) |
| `last_update` | object | The last [update](#update-belt-on-a-remote) the remote was asked for and how it went. Omitted when never asked. |

`public_key` is not part of the response.

#### Profile

Each `ProfileDTO` in `profiles`:

| Field | Type | Description |
|-------|------|-------------|
| `remote_id` | string | Owning remote |
| `harness_kind` | string | Harness registry id (`claude`, `codex`, ...) |
| `display_name` | string | Product name from the harness registry (for example `Claude Code`) |
| `vendor` | string | Vendor name from the registry |
| `name` | string | User label for this slot on the remote |
| `command` | string | Harness binary path on the remote |
| `args` | string[] | Extra launch arguments |
| `status` | string | `idle`, `busy`, or `unavailable` |
| `max_concurrent` | integer | Concurrency cap for this profile |
| `capabilities` | object | What hosted sessions can do (`steer`, `approvals`, `interrupt`, `resume`, `tools`, `images`, `files`). Omitted when not reported yet; treat as all false. |
| `account` | object | Who the harness is signed in as, from the daemon's last probe or a running session. Omitted until known. |
| `limits` | object | Plan rate-limit windows at `checked_at`. Omitted until known. |

##### Profile `account`

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `authenticated`, `unauthenticated` or `unknown` |
| `type` | string | `subscription`, `api_key` or `cloud` |
| `plan` | string | Vendor plan id, for example `max` |
| `plan_label` | string | Display name, for example `Max 20x` |
| `email` | string | Account email, when the harness reports one |
| `workspace` | string | Organization or workspace name, when reported |

##### Profile `limits`

| Field | Type | Description |
|-------|------|-------------|
| `checked_at` | string (RFC 3339) | When the windows were read |
| `windows` | array | One entry per rate-limit window |
| `unavailable` | object | Why there are no windows: `reason` (`unsupported` or `probe_failed`) and optional `message` |

Each window:

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Stable id per harness, for example `five_hour` or `seven_day` |
| `kind` | string | `session`, `weekly`, `monthly` or `other` |
| `label` | string | Display name |
| `used_percent` | number | 0 to 100 |
| `resets_at` | string (RFC 3339) | When the window resets; omitted when unknown |
| `duration_minutes` | integer | Window length, when known |

A running harness session can report a newer account and limits between probes.

| Profile `status` | Meaning |
|------------------|---------|
| `idle` | Ready to take a run |
| `busy` | At its concurrency limit |
| `unavailable` | Logged out, removed from the host, rate-limited, or disabled |

Remote lifecycle `status`:

| `status` | Meaning |
|----------|---------|
| `pending` | Registered, not yet connected |
| `running` | The daemon holds an open WebSocket to the API |
| `disconnected` | The WebSocket closed. Reversible when the daemon reconnects. |
| `stopped` | Finalized after staying `disconnected` for about **30 seconds** |

**Connection vs heartbeat.** `status` follows the daemon's **WebSocket connection**, not heartbeat age. Opening the socket moves the remote to `running` from `pending`, `disconnected`, or `stopped`. Closing it (or losing the connection without a clean close) moves a `running` remote to `disconnected`. The API reconciles connection leases about every **10 seconds** as a safety net: a remote that drops off presence for two rounds is marked `disconnected`; one that is present but not `running` is revived.

`heartbeat_at` is **last seen** only. WebSocket and HTTP heartbeats stamp it; they do not change `status`. Use it for display ("last seen") and for live `system_info` updates on the WebSocket path.

While `disconnected`, dispatch and harness launch return `409 remote_offline` because the daemon is not connected. **Exec runs and harness sessions already running on the host keep going** through a brief disconnect (for example Wi‑Fi blip). They fail only when the remote reaches `stopped` — after the disconnect grace — or when you cancel them.

#### Harness version support

Each profile's `support` object describes how the installed `version` compares to versions inference has tested for that harness. The API computes it from the registry when serializing profiles — it does not run the harness binary.

| `support.level` | Meaning |
|-----------------|---------|
| `supported` | Inside the tested range |
| `newer-than-tested` | Newer than anything tested. Sessions still run; the UI may warn. |
| `older-than-tested` | Older than the tested range but above the driver's minimum. Sessions still run; the UI may warn. |
| `older-than-supported` | Below a floor the session driver needs (for example a missing RPC or app-server API). **Launch and new turns are refused** until you upgrade on the host. |
| `unknown` | Version could not be parsed, the harness is not in the registry, or inference has not published a tested range yet. Sessions may still run; treat as a warning. |

| Field | Description |
|-------|-------------|
| `reason` | Human-readable sentence explaining the verdict |
| `version` | Parsed dotted version from the daemon's `--version` output |
| `tested_min`, `tested_max` | Oldest and newest versions inference has tested for this harness |
| `requires` | Minimum version the driver depends on, when defined |
| `upgrade_cmd` | Shell command to install the latest release over the one on the machine (from the registry) |

The workspace **Remotes** machine page maps `support` onto each launcher card: `version` beside the vendor; yellow warnings for `newer-than-tested`, `older-than-tested`, and `unknown` on **ready** cards (sessions still start); a **too old** pill with `reason` and a copyable `upgrade_cmd` when `level` is `older-than-supported` (no launch until you upgrade on the host). API clients should mirror that split — warn on untested builds, block launch when below the driver floor. When `level` is `older-than-supported`, expect `409 harness_too_old` from [Launch harness session](#launch-harness-session) and `409 conflict` from [Send chat message](/docs/api/rest/agents#send-chat-message) on harness-backed chats — both include `reason` and how to upgrade. The remote daemon applies the same floor before opening a session: it re-reads the installed version at launch (so upgrades since registration count) and refuses with `reason` and `upgrade_cmd` when the CLI is still below `requires` (for example Pi **&lt; 0.80.4**, Codex **&lt; 0.56.0**).

---

## List remotes

`GET /remotes` or `POST /remotes/list`

Cursor-paginated list of remotes. Requires **`remotes:read`**.

```bash
curl -X POST https://api.inference.sh/remotes/list \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"limit": 20}'
```

---

## Get remote

`GET /remotes/{id}`

Returns a `RemoteDTO` with `profiles` included. Requires **`remotes:read`**. Use the `id` that `belt remote start` prints when it connects, or one from the list endpoint.

---

## List harness sessions

`GET /remotes/{id}/sessions`

Asks the connected daemon which harness sessions exist on the machine — ones inference started and ones a person started locally — and whether a process holds each session right now. Use the result to offer continue on an idle session or watch an active one. Requires **`remotes:read`**.

The handler forwards `remote_sessions_list` over the remote WebSocket and waits for `remote_sessions_listed` (up to **10 seconds**). The remote must be **`running`** and registered with **`agents_enabled: true`** (`belt remote start --agents`).

### Query

| Parameter | Description |
|-----------|-------------|
| `cwd` | Optional. When set, only sessions whose working directory matches are returned (same filter as the WebSocket `cwd` field). |

```bash
curl "https://api.inference.sh/remotes/remote_abc123/sessions?cwd=/home/you/project" \
  -H "Authorization: Bearer inf_your_key"
```

### Response

Same shape as the WebSocket `remote_sessions_listed` frame:

| Field | Type | Description |
|-------|------|-------------|
| `request_id` | string | Correlates with the daemon request |
| `sessions` | array | Harness sessions on disk (metadata only; no transcript bodies). At most **500** entries, newest first. |
| `errors` | array | Optional per-harness store read failures |

Each `sessions[]` entry:

| Field | Type | Description |
|-------|------|-------------|
| `harness` | string | Harness id (for example `claude`, `codex`) |
| `id` | string | Harness session id on disk (pass as `resume_session_id` when launching) |
| `cwd` | string | Working directory, when known |
| `title` | string | Display title from the harness store, when known |
| `updated` | string | Last metadata update (RFC 3339) |
| `live` | object | Whether a process holds the session now (see below) |

`live` (`SessionLiveness`):

| Field | Type | Description |
|-------|------|-------------|
| `state` | string | `active`, `idle`, or `unknown` |
| `evidence` | string | What decided liveness — for example `lock-file`, `no-process`, `process-in-cwd`, `recent-write`, or `none` |
| `heuristic` | boolean | `true` when liveness is inferred rather than proven (for example from process cwd or recent writes) |
| `pid` | integer | OS process id, when known |
| `detail` | string | Extra context from the harness transcript layer |

### Errors

| HTTP | Code | Cause |
|------|------|-------|
| `409` | `agents_disabled` | The daemon was not started with `--agents` |
| `409` | `remote_offline` | The remote is not connected |
| `504` | `remote_timeout` | The daemon did not answer within 10 seconds |

---

## Launch harness chat

`POST /remotes/{id}/sessions`

Opens a new chat with a harness on a connected remote: pick a **profile**, set the **working directory** on that machine, and optionally **resume** a session the harness already has (from [List harness sessions](#list-harness-sessions)). Requires **`remotes:write`** and **`agents:execute`**.

The remote must be **`running`** with **`agents_enabled: true`** (`belt remote start --agents`). The profile must belong to that remote and report non-null **`capabilities`** (the daemon can drive the harness).

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `profile_id` | string | Yes | Profile id from `GET /remotes/{id}` → `profiles[].id` |
| `work_dir` | string | No | Folder on the remote where the harness session runs. Leading and trailing whitespace is trimmed. Empty uses the daemon default. Stored on the chat as `work_dir` (folder-bound sessions; see [Get chat](/docs/api/rest/agents#get-chat)). |
| `resume_session_id` | string | No | Harness session id from a list entry's `id` field — continues that session instead of starting fresh. Sets `harness_session_id` on the new chat. |

```bash
curl -X POST "https://api.inference.sh/remotes/remote_abc123/sessions" \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "profile_id": "prof_xyz789",
    "work_dir": "/home/you/projects/api",
    "resume_session_id": "sess_abc"
  }'
```

### Response

Returns a `ChatDTO` (same shape as [Get chat](/docs/api/rest/agents#get-chat)) with `agent_id` set to the profile's harness agent, `work_dir` from the request, and `harness_session_id` when `resume_session_id` was sent. Send the first user message with `POST /agents/run`, `POST /chats/{id}/messages`, or open the chat in the workspace.

The API creates the backing agent on first launch for that profile (`harness` set to the profile's registry id, `profile_id` and `remote_id` bound to the machine). Reconnecting the daemon does not duplicate agents.

### Errors

| HTTP | Code | Cause |
|------|------|-------|
| `400` | `invalid_request` | Missing `profile_id` or invalid JSON body |
| `404` | `profile_not_found` | `profile_id` is not on this remote |
| `409` | `agents_disabled` | The daemon was not started with `--agents` |
| `409` | `harness_not_drivable` | The profile has no session `capabilities` (harness not driveable from inference) |

---

## List harness catalog

`GET /harnesses`

Returns every agent harness inference can drive on a remote, from the [agentprotocol harness registry](https://github.com/inference-sh/agentprotocol). The list includes only registry entries that have a **session driver** (for example Claude Code, Codex, Pi, and ACP-backed CLIs). Registry entries with no driver — IDE-only agents and backends that only describe a harness — are omitted.

Use this to build launch UIs: show the full set of driveable harness kinds, then match each entry's `id` to `GET /remotes/{id}` → `profiles[].harness_kind` to show which ones that machine installed and whether each profile is `idle`.

Requires **`remotes:read`**.

```bash
curl https://api.inference.sh/harnesses \
  -H "Authorization: Bearer inf_your_key"
```

### Response

An array of catalog entries:

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Harness registry id (`claude`, `codex`, `gemini`, …) |
| `display_name` | string | Product name from the registry |
| `vendor` | string | Vendor name |
| `driver` | string | How the daemon drives sessions: `claude-code`, `codex`, `pi`, or `acp` |
| `capabilities` | object | Driver defaults for this harness kind: `steer`, `approvals`, `interrupt`, `resume`, `tools` |

`capabilities` here come from the driver implementation, not from a specific machine. Per-host behavior and login state still come from daemon registration and each profile's `capabilities` and `status`.

---

## Update remote

`POST /remotes/{id}`

Updates a remote you own and returns the updated `RemoteDTO`. Requires **`remotes:write`**.

The body is a full remote object. The update writes every mutable field, so a field you omit is reset to its zero value. Send the object from `GET /remotes/{id}` with your changes applied.

`public_key` is one of those fields and no response returns it. An update that omits it clears the key, and the next `belt remote` start on that machine registers a new remote.

`belt remote start` overwrites `status`, `system_info`, `remote_version`, `exec_enabled`, and `agents_enabled` on its next registration, and `name` only when started with `--name`.

---

## Update belt on a remote

`POST /remotes/{id}/update`

Asks the remote's daemon to update belt to the latest release in place, the way an engine updates. Requires **`remotes:write`** and manage on the remote (its owner, or a `write` share). No request body.

The daemon checks the release and answers within a few seconds. When a newer belt is available it then:

1. refuses new commands, terminals, agent sessions and prompts (`this remote is updating belt; try again in a minute`)
2. waits for running commands and agent turns to finish, up to **10 minutes**
3. installs the release while still connected (download, SHA-256 check, replace its own binary)
4. restarts with the same arguments the way it was started (detached, as a service or in the foreground), with the same name and settings. On Linux and macOS it restarts in the same process; on Windows it starts a new one.

It reconnects on the new version, which registration records as `remote_version`. Idle agent sessions and open terminals end with the restart. If the install fails, the daemon takes work again on its current version without restarting, and `remote_version` stays as it was.

The daemon fetches the release itself from the CLI release manifest; the API never names a binary.

The response is the daemon's first answer. What happens next shows in the remote's `last_update`:

| Field | Type | Description |
|-------|------|-------------|
| `state` | string | See below |
| `target` | string | Engines: what was asked for (`stable`, `canary` or a version). Empty for remotes, which always take the latest release. |
| `from` | string | Version the machine ran when asked |
| `to` | string | Version it installs or was already on, when known |
| `error` | string | Why, for `refused` and `failed` |
| `at` | string | When `state` was reached |

| State | Meaning |
|-------|---------|
| `requested` | Sent; the daemon hasn't answered. It stays here when the request times out (`504 remote_timeout` after 30 seconds). |
| `installing` | A different release is being installed; the machine finishes its running work and restarts on it |
| `installed` | The machine reconnected on `to` |
| `current` | Already on `to`; nothing installed |
| `refused` | The binary can't replace itself: a development build, a package manager's install, or a directory the machine's user can't write to. `error` says what to do on the machine. |
| `failed` | The release couldn't be checked, downloaded or installed, or the request couldn't be sent to the daemon after the online check, so nothing will answer. `error` says why. The machine keeps its version. |

An install that fails after the `updating` answer turns `last_update` to `failed` with the reason (belt v1.28.4 and later). The remote's page shows it under **last update**.

### Response

| Field | Type | Description |
|-------|------|-------------|
| `request_id` | string | Request id |
| `state` | string | `updating` (draining, then installs and restarts), `current` (already on the latest), `unsupported` (installed by a package manager, a development build, or a binary in a directory the daemon's user cannot write), or `failed` (the release could not be checked) |
| `from` | string | Version the daemon runs |
| `to` | string | Latest release, when known |
| `error` | string | Why it cannot update, for `unsupported` and `failed` |

### Errors

| HTTP | Code | Cause |
|------|------|-------|
| `409` | `remote_offline` | The remote is not connected |
| `504` | `remote_timeout` | The daemon did not answer within 30 seconds (belt older than the release that added updates in place) |

---

## Set remote tags

`PUT /remotes/{id}/tags`

Replaces the remote's `tags` array in full. Requires **`remotes:write`**, read access to the remote, and the **`manage_policy`** capability on the remote's workspace (otherwise `403`). Tags decide which workspace policy rules apply to the machine, so setting them is gated like editing policy rather than by ownership. An invalid tag returns `400` `validation_error`.

### Request

| Field | Type | Description |
|-------|------|-------------|
| `tags` | string[] | Complete tag list for the remote |

Tags are normalized server-side: trimmed, lowercased, deduplicated, and sorted. Each tag must match `^[a-z0-9][a-z0-9_.-]{0,39}$` (lowercase letters, digits, `-`, `_`, or `.`). At most **20** tags per remote.

```json
{ "tags": ["prod", "eu-west"] }
```

### Response

`RemoteDTO` with the updated `tags` field.

From the CLI, `belt remote tag <id> [tag...]` reads the current tags with `GET /remotes/{id}`, merges additions and `--rm` removals, then sends this request. See [Remotes guide — Tags](/docs/extend/remote#tags).

---

## Delete remote

`DELETE /remotes/{id}`

Deletes a remote you own. Requires **`remotes:write`**. From the CLI, `belt remote rm <id>` sends the same request (confirmation prompt unless `--yes`).

```bash
belt remote rm remote_abc123
curl -X DELETE https://api.inference.sh/remotes/remote_abc123 \
  -H "Authorization: Bearer inf_your_key"
```

Deleting a row does not stop a daemon still running on the host. Stopping or reinstalling a machine often leaves an old registration; `belt remote list` hints at `belt remote rm` when a remote has not been seen for over a week.

---

## Launch harness session

`POST /remotes/{id}/sessions`

Opens a **new chat** with a harness profile on a connected remote — for example Claude Code, Cursor, or Pi on your laptop, in a project folder. Requires **`remotes:write`** and **`agents:execute`**. The remote must have `agents_enabled: true`, the profile must belong to that remote, and the daemon must report `capabilities` for the harness (undriveable harnesses are not offered).

The first launch for a profile creates a harness **agent** (for example `Claude Code · work-laptop`) and reuses it on later launches. Connecting `belt remote` alone does **not** create agents anymore.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `profile_id` | string | Yes | Profile id from `GET /remotes/{id}` → `profiles[].id` |
| `work_dir` | string | No | Folder the harness works in on the remote. Empty uses the daemon default. |
| `resume_session_id` | string | No | Continue a harness session from [List harness sessions](#list-harness-sessions) instead of starting fresh |

### Response

Returns a `ChatDTO` (same shape as [Get chat](/docs/api/rest/agents#get-chat)), including:

| Field | Description |
|-------|-------------|
| `name` | Typically `{display_name} · {remote name}` |
| `work_dir` | Normalized working directory for this chat |
| `harness_session_id` | Set when `resume_session_id` was provided |
| `agent_id` | The harness agent created or reused for this profile |

Send the first user message with [`POST /chats/:id/messages`](/docs/api/rest/agents#send-chat-message) or [`POST /agents/run`](/docs/api/rest/agents#run-agent-recommended) to start the harness turn.

### Runtime on the remote

The chat exists in the API as soon as this endpoint returns; the harness process on the machine starts when the platform asks the daemon to open a session for that chat (typically on the first user message). Progress flows over the remote WebSocket (see [Harness session frames](#harness-session-frames)):

| Daemon → server event | Meaning |
|-----------------------|---------|
| `remote_session_opened` | Harness process is ready (`harness_session_id`, `resumed`, `seq`) |
| `remote_session_event` | Sequenced harness output (`AgentEvent` payload, monotonic `seq`) |
| `remote_session_closed` | Session ended; `error` when open or a turn failed |

The daemon waits up to **60 seconds** for the harness to finish opening. If the CLI is stuck on sign-in, an update prompt, or another interactive gate, it sends `remote_session_closed` with an error such as `{display name} did not start a session within 1m0s. Check it works in a terminal on this machine: it may be waiting to sign in or to update`. The API fails the active run with that reason. A harness that starts after the deadline is closed on the machine so the slot is freed.

When a user message cannot be delivered (no live session on the daemon), the daemon sends `remote_session_closed` so the run fails instead of staying in progress. When `Prompt` fails on a live session, the daemon emits an `error` agent event on the run before reporting failure.

**Turn timeouts (belt with agentprotocol v0.12.1+).** Opening a session is bounded by **60 seconds** (above). Each user message is bounded only until the harness shows its **first sign of work** — an assistant message, tool call, permission request, plan update, or similar. Session bookkeeping and echoes of the user's prompt do not count. If nothing qualifies within **2 minutes**, the daemon emits an `error` event with code `no_response`, cancels the in-flight prompt on the harness, and fails the run. After work has started, there is **no fixed total prompt timeout**; long tool loops can run until the harness completes the turn.

**Harness stderr on failures.** Session drivers keep the last **4 KiB** of the harness process stderr. Open failures, `no_response`, process exit, and other driver errors append `; agent stderr: …` to the error message on the `error` event and on `remote_session_closed` when applicable, so API clients and the workspace can surface CLI output without SSH access to the host.

### Errors

| HTTP | Code | Cause |
|------|------|-------|
| `400` | `invalid_request` | Missing or invalid body, or `profile_id` is empty |
| `404` | `profile_not_found` | No profile with that id on this remote |
| `409` | `agents_disabled` | The daemon was not started with `--agents` |
| `409` | `harness_not_drivable` | The profile has no reported capabilities |
| `409` | `harness_too_old` | Installed harness is below the driver's minimum (`profiles[].support.level` is `older-than-supported`). The error message includes what is missing and `upgrade_cmd`. |

```bash
curl -X POST "https://api.inference.sh/remotes/remote_abc123/sessions" \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"profile_id": "prof_xyz", "work_dir": "/home/you/project"}'
```

---

## Heartbeat

`POST /remotes/{id}/heartbeat`

Records a liveness ping and updates `heartbeat_at`. Requires **`remotes:write`**. `belt remote` sends its heartbeats over the WebSocket. This endpoint is the HTTP equivalent. The body is optional. The response is a bare empty JSON object without the standard envelope:

```json
{}
```

`revived` is always `false`: heartbeats no longer change lifecycle `status`. Reconnection over the WebSocket moves a remote back to `running`.

To refresh `system_info` with live CPU, RAM, and home-disk usage, the daemon sends `system_info` on every third WebSocket `remote_heartbeat` frame instead of this HTTP route.

---

## Dispatch exec

`POST /remotes/{id}/execs`

Runs a command on a connected remote. Returns an `ExecRunDTO` immediately. Output is read separately. The [Exec Runs API](/docs/api/rest/exec-runs) page lists every response field.

### Request

| Field | Type | Description |
|-------|------|-------------|
| `command` | string | Executable name or path (required) |
| `args` | string[] | Arguments |
| `cwd` | string | Working directory on the remote |
| `env` | string[] | Extra `KEY=VALUE` entries |
| `pty` | boolean | Run under a pseudo-terminal |
| `timeout_ms` | integer | Timeout in milliseconds (`0` = no limit) |

```bash
curl -X POST "https://api.inference.sh/remotes/remote_abc123/execs" \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"command": "uname", "args": ["-a"]}'
```

### Errors

| HTTP | Code | Cause |
|------|------|-------|
| `400` | `invalid_request` | `command` is empty or the body is not valid JSON |
| `403` | `exec_disabled` | The daemon was not started with `belt remote start --exec` |
| `409` | `remote_offline` | The remote is not connected |

---

## Get exec run

`GET /execs/{id}`

Returns an `ExecRunDTO`: command, status, exit code, timing, and `last_seq` (highest output sequence number).

| `status` | Terminal? | Meaning |
|----------|-----------|---------|
| `pending` | No | Created, not yet sent to the host |
| `running` | No | Sent to the host |
| `exited` | Yes | Process ended. `exit_code` is set. |
| `killed` | Yes | Ended without an exit code: signal, timeout, cancel, remote disconnect, or the daemon refused the run |
| `denied` | Yes | The API could not deliver the command to the daemon |

---

## List exec runs

`GET /execs` or `POST /execs/list`

Cursor-paginated list of exec runs. Requires **`remotes:read`**.

---

## Read exec output

`GET /execs/{id}/output?after_seq={n}`

Returns an array of `ExecRunOutputDTO` chunks with `seq > after_seq`, in order, up to 2000 per call. Poll with an increasing `after_seq` until the run reaches a terminal status.

| Field | Type | Description |
|-------|------|-------------|
| `seq` | integer | Sequence number within the run |
| `stream` | string | `stdout` or `stderr` |
| `data` | string | Output bytes, base64-encoded |

```bash
curl "https://api.inference.sh/execs/exec_abc123/output?after_seq=0" \
  -H "Authorization: Bearer inf_your_key"
```

---

## Signal exec

`POST /execs/{id}/signal`

Kills a running exec. Returns `{"ok": true}`. A run that is already terminal returns the same response and nothing is sent.

```bash
curl -X POST "https://api.inference.sh/execs/exec_abc123/signal" \
  -H "Authorization: Bearer inf_your_key"
```

---

## WebSocket protocol

The daemon connects to `/ws/remotes/{id}` with `Authorization: Bearer <api-key>` and optional `X-Team-ID`. Requires **`remotes:write`**.

Server to remote:

| Event | Purpose |
|-------|---------|
| `remote_exec_start` | Start a command (`exec_run_id`, `command`, `args`, `cwd`, `env`, `pty`, `timeout_ms`) |
| `remote_exec_signal` | Kill a run |
| `remote_session_open` | Start or resume a harness session for a chat (`chat_id`, `harness`, `cwd`, optional `resume_session_id`, and `tools` when the profile has `capabilities.tools`: the platform catalog tool definitions) |
| `remote_session_tool_result` | Answer a catalog tool call from the harness (`chat_id`, `call_id`, `text`, optional `is_error`). See [Platform catalog tools](/docs/extend/remote#platform-catalog-tools) |
| `remote_session_prompt` | Send a user message for a turn (`chat_id`, `run_id`, `text`, optional `files`: each `{uri, filename, content_type, size}`). Daemons older than belt v1.21.0 read only `text` |
| `remote_session_interrupt` | Cancel the turn in flight, keep the session (`chat_id`) |
| `remote_session_resolve` | Answer a harness permission request (`chat_id`, `request_id`, `decision`, optional `scope`, `reason`). `scope` is `once` (one prompt) or `session` (the chat already allows that tool, or the person chose always allow; the harness may skip further prompts for that tool this session). See [Remote harness runs](/docs/api/rest/agents#remote-harness-runs). |
| `remote_session_close` | End the chat's session (`chat_id`) |
| `remote_session_ack` | Drop buffered events stored through `seq` (`chat_id`, `seq`) |
| `remote_session_replay` | Resend buffered events after `after_seq` (`chat_id`, `after_seq`) |
| `remote_sessions_list` | List harness sessions on disk (`request_id`, optional `cwd` filter) |
| `remote_update` | Update belt in place (`request_id`); see [Update belt on a remote](#update-belt-on-a-remote) |

Remote to server:

| Event | Purpose |
|-------|---------|
| `remote_heartbeat` | Last-seen ping (updates `heartbeat_at`). Optional `system_info` on every third beat (~15s at default cadence) with live CPU, RAM, and home volume usage; stored on the remote when present. Optional `harnesses` with probed `account` and `limits`, right after connect and every 5 minutes, reconciled into `profiles` as at registration. Lifecycle `status` follows the socket, not each beat. |
| `remote_exec_output` | Sequenced stdout/stderr chunk |
| `remote_updating` | Answer to `remote_update` (`request_id`, `state`, `from`, `to`, `error`) |
| `remote_exec_exit` | Run finished (`exit_code`, `error`, `timed_out`, `duration_ms`) |
| `remote_session_opened` | Session is live (`chat_id`, `harness_session_id`, `resumed`, `seq`) — `seq` is the last event number the session has produced (`0` for a process just launched; resets when the harness process restarts) |
| `remote_session_event` | One harness event (`chat_id`, `seq`, `event`, optional `gap` on replay) |
| `remote_session_closed` | Session ended (`chat_id`, optional `error`, for example when `agents_enabled` is false) |
| `remote_session_tool_call` | The harness called a platform catalog tool (`chat_id`, `run_id`, `call_id`, `name`, `arguments`, optional `tool_invocation_id`). The API runs it and answers with `remote_session_tool_result` |
| `remote_sessions_listed` | Answer to `remote_sessions_list` (`request_id`, `sessions[]`, optional per-harness `errors[]`) |

Each entry in `sessions` includes `harness`, `id`, `cwd`, `title`, `updated`, and `live` (whether a process holds the session, with evidence and heuristic flags from the harness transcript layer). Listing reads metadata only, never transcript bodies.

Harness session frames require `agents_enabled: true` on the remote (set when the daemon starts with `belt remote start --agents`). The protocol also defines `remote_terminal_*` frames for interactive PTY sessions on the host. Opening, prompting, and closing harness sessions happens only over this socket; [`GET /remotes/{id}/sessions`](#list-harness-sessions) is the HTTP wrapper for listing metadata.

**Ordering.** The daemon handles incoming frames in parallel lanes keyed by `chat_id`, `exec_run_id`, or terminal `session_id`, so one slow harness session does not block exec output or another chat. Outbound frames use the same keys so session and exec events stay in order within each lane.

**Open vs launch.** `remote_session_open` reserves the chat's slot before the daemon replies with `remote_session_opened`; the harness process starts in the background. A `remote_session_prompt` sent immediately after open waits for that launch instead of racing an empty slot.

**Persistence and replay.** After each stored `remote_session_event`, the API sends `remote_session_ack` with the persisted sequence so the daemon can trim its replay buffer. On reconnect, the API sends `remote_session_replay` with `after_seq` set to the chat's stored position (`harness_event_seq`); events at or below that sequence are ignored. If the daemon restarts and reports a lower sequence in `remote_session_opened`, the stored position resets.

When a remote transitions to `disconnected` or `stopped`, unfinished **exec runs** and active **harness agent runs** on that remote fail (not retried). See [Exec Runs API — Status](/docs/api/rest/exec-runs) and [Remote harness runs](/docs/api/rest/agents#remote-harness-runs).

→ [Remotes guide — Agent harness sessions](/docs/extend/remote#agent-harness-sessions)

### Harness session frames

When `agents_enabled` is true, the API and daemon exchange harness chat control on the same WebSocket:

**Server → remote**

| Event | Purpose |
|-------|---------|
| `remote_session_open` | Start or resume a harness for a chat (`chat_id`, harness kind, `cwd`, optional `resume_session_id`, `tools` for `capabilities.tools` profiles) |
| `remote_session_tool_result` | Catalog tool result for a prior `remote_session_tool_call` |
| `remote_session_prompt` | User message for a turn (`chat_id`, `run_id`, text, attached files) |
| `remote_session_interrupt` | Cancel the turn in flight |
| `remote_session_resolve` | Answer a permission prompt (`request_id`, `decision`, optional `scope`) |
| `remote_session_close` | End the chat's harness session |
| `remote_session_ack` | Drop buffered events through `seq` |
| `remote_session_replay` | Resend events after `after_seq` |
| `remote_sessions_list` | List harness sessions on the host (HTTP `GET /remotes/{id}/sessions` uses the same path) |

**Remote → server**

| Event | Purpose |
|-------|---------|
| `remote_session_opened` | Open succeeded |
| `remote_session_event` | One sequenced harness event |
| `remote_session_closed` | Session ended (`error` when not a clean close) |
| `remote_session_tool_call` | The harness called a catalog tool; the API runs it and returns `remote_session_tool_result` |
| `remote_sessions_listed` | Answer to `remote_sessions_list` |

Open is bounded by a **60 second** deadline on the daemon. See [Launch harness session — Runtime on the remote](#runtime-on-the-remote).

---

## Scopes

| Scope | Access |
|-------|--------|
| `remotes:read` | List and get remotes, list harness sessions, list and get exec runs, read output |
| `remotes:write` | Register, update, and delete remotes, heartbeat, WebSocket connect, dispatch execs, signal runs, [launch harness chats](#launch-harness-chat) (also needs `agents:execute`), list, add and remove [shares](/docs/api/rest/overview#share-routes) (`GET /remotes/{id}/shares`, `POST /remotes/{id}/share`, `DELETE /remotes/{id}/share/{userId}`, `DELETE /remotes/{id}/team-share`, `DELETE /remotes/{id}/org-share`) |
| `agents:execute` | Required together with `remotes:write` for `POST /remotes/{id}/sessions` |
