# Remotes

> A remote is a machine you connect to inference with belt remote start. It hosts agent harnesses and, when you opt in, runs agent sessions or shell commands the platform dispatches.

**URL:** https://inference.sh/docs/extend/remote
**Last updated:** 2026-10-31

---

A **remote** is a machine you connect to inference with `belt remote start`. It hosts agent harnesses and, when you opt in, runs agent sessions or shell commands the platform dispatches.

The daemon connects **outbound** to inference over an authenticated WebSocket. Nothing listens on your machine, and the daemon runs only what the server sends.

A [managed sandbox](/docs/extend/managed-sandboxes) is a remote the platform runs for you: a VM that starts belt and registers the same way.

---

## In the workspace

After you run `belt remote`, open **Resources** → **Remotes** in the sidebar ([`/remotes`](https://app.inference.sh/remotes)) to see connection status, harness profiles, and whether exec is enabled on each host.

Select a machine to open its page:

- **Agents** — one card per harness profile on the machine. **Launch** asks for the folder the harness should work in and opens a chat with the harness-backed agent for that profile. A card cannot launch while the machine is offline, the daemon was started without `--agents`, the harness is signed out or too old, or the profile is busy.
- **Machine** — belt version, hostname, OS, CPU, whether agent sessions and command execution are on, and last seen.
- **Rules** — **allow**, **ask**, and **deny** rows for commands, workspace folders, and harness tools on **this machine**. They apply to every `remote_exec` dispatch and harness call on the remote, and only the remote's owner or someone with a `write` share can change them (see [Agent and remote permission rules](/docs/api/rest/agents#agent-and-remote-permission-rules)).
- **Sessions on this machine** — harness sessions inference started and sessions opened locally. **Continue** on an idle session reopens it in a chat.

---

## When to use a remote

| Use case | What the remote provides |
|----------|--------------------------|
| Agent harness hosting | Keeps coding agents (Claude Code, Codex, Cursor, and similar) logged in on a machine inference can drive |
| Agent sessions | Runs harness processes for chats the API opens on this host (`belt remote start --agents`) |
| Command execution | Runs shell commands on a connected host and streams stdout/stderr back (`belt remote exec`) |

A remote is not a private inference **engine**. Engines run GPU/CPU workloads from the task queue. Remotes host harnesses and optional shell execution on a machine you control. See [Private workers](/docs/private/why) for compute on your hardware.

---

## Security model

- **Outbound only.** The daemon dials `wss://api.inference.sh/ws/remotes/{id}` with your API key. No inbound ports are opened.
- **Your OS user.** Commands and harness sessions run as the user who started `belt remote start`. That user is the blast radius.
- **Exec is opt-in.** Command execution is disabled unless you start the daemon with `--exec`. Without it the daemon refuses `remote_exec_start` frames, and the API refuses the dispatch with `403 exec_disabled`.
- **Agent sessions are opt-in.** Inference may drive harness sessions (Claude Code, Codex, Cursor, and similar) on your machine only when you start the daemon with `--agents`. Without it the daemon refuses `remote_session_open` frames, ignores other session frames, and answers opens with `agent sessions are disabled on this remote (start it with --agents)`.
- **Permissions stay with you.** The daemon never auto-approves harness tool calls locally. Approval requests go through inference.sh; a person allows or denies them, or has already chosen **always allow** for that tool in the chat, in which case the API answers the harness without parking the run. See [Remote harness runs](/docs/api/rest/agents#remote-harness-runs).
- **Destructive CLI gate.** `belt remote exec` prompts for confirmation, or requires `--yes` in non-interactive mode.
- **Yours unless you share it.** Teammates can see your remote. Running commands, opening harness sessions and placing agents on it needs you (its owner) or a `use` or `write` share. Changing it, deleting it, editing its policy rules and managing its shares needs you or a `write` share. Anyone else who can see it gets `403` ("this remote is another person's machine and is not shared with you for this"); admins do not bypass this. Heartbeats and the daemon connection are yours alone. See [Visibility and sharing](/docs/api/rest/overview#visibility-and-sharing). Exec runs and their output are visible only to the person who started them and to the remote's owner; environment variable values are never returned by the API.
- **Policy paths follow symlinks (belt v1.19.13+).** When workspace policy evaluates remote exec, workspace, or harness paths, the API asks a connected daemon with `--exec` or `--agents` to resolve absolute paths through symlinks on the host. Allow rules must match a path both as written and where it leads, so a symlink cannot bypass a rule on the literal path. If the daemon is too old, offline, or does not answer within a few seconds, paths are judged as written; an otherwise-allowing rule may surface as a person approval instead.
- **`~` paths use the daemon's home.** On registration the daemon may send `home` (the account's absolute home directory). Workspace rules and calls that name `~/…` resolve against that path on the machine before symlink resolution. When `home` is missing (older daemon), `~` paths are judged as written.

Run the daemon under a dedicated user or inside a container if you need a smaller blast radius.

---

## Connect a machine

### Prerequisites

- [Belt CLI](/docs/extend/cli-setup) installed and logged in (`belt login`)
- An API key or session with the `remotes:write` scope. The `standard` key preset includes it.

### Start the daemon

```bash
belt remote start
belt remote start --name "work-laptop"    # custom display name; later starts keep it
belt remote start --exec                  # allow command execution on this host
belt remote start --agents                # allow harness agent sessions on this host
belt remote start --exec --agents         # both exec and harness sessions
```

On start, belt:

1. Creates or loads a machine identity key (`remote_ed25519`) in `~/.inferencesh/`. When `BELT_CONFIG_DIR` is set, the key lives in that directory instead. The private key stays on the machine; only the public key is sent.
2. Probes the machine for installed agent harnesses (see [Harness discovery](#harness-discovery)). The CLI shows **discovering agent harnesses...** as its own step before registration.
3. Registers the remote with `POST /remotes`, sending the name (default: hostname), public key, belt version, system info, the daemon account's home directory (used to expand `~` in workspace rules; stored, not returned on remote API responses), probe results, and opt-in flags (`exec_enabled`, `agents_enabled`)
4. Connects to the remote WebSocket and sends a heartbeat every 5 seconds (every third beat also carries a fresh `system_info` snapshot with live CPU, RAM, and home-disk usage)

**Foreground vs background.** With no flags, `belt remote start` holds the terminal open until you stop it:

| Input | Effect |
|-------|--------|
| **d** or **Ctrl-D** | Detach: hand the connection to a background daemon and return your shell. Same as starting with `--detach` / `-d`. |
| **Ctrl-C** | Disconnect and exit; the API marks the remote `disconnected` when the WebSocket closes, and `running` again when you start `belt remote` and the socket reconnects. |

Detaching is only available on an interactive terminal (not when stdin is piped or in CI). Only **one** `belt remote start` may run per machine at a time (foreground or detached), keyed on the machine identity in `~/.inferencesh/`. That instance holds a lock that `belt remote status` and `belt remote stop` use — including foreground sessions, which `status` labels `mode: foreground, in the terminal that started it` instead of a log file path.

A second `belt remote start` with the **same** `--exec` and `--agents` flags exits with `a remote is already running on this machine (pid …)`; run `belt remote stop` or disconnect that session first. If you start again with **different** `--exec` or `--agents` values, belt offers to **restart** the running daemon with the new permissions (stopping ends any harness agent sessions on that machine, so it asks for confirmation). Decline the prompt or run in non-interactive mode without `--yes`, and belt leaves the running remote unchanged — use `belt remote stop`, then start again with the flags you want. Press **d** to detach: belt closes the foreground connection, releases the lock, and starts a background daemon with the same flags. If agent sessions are running in the foreground process, they are its children and would end on detach; belt asks you to press **d** a second time to confirm.

Flags such as `--exec`, `--agents`, and `--name` belong to **`belt remote start`**, not the bare `belt remote` group. Typing `belt remote --exec` prints an error that names `belt remote start --exec` instead of a generic unknown-flag message.

To start in the background immediately:

```bash
belt remote start --detach    # or -d
belt remote status            # pid, remote id, connection state and how long it has held
belt remote logs -f           # follow the daemon log
belt remote stop              # stop the running remote (foreground or background)
```

**Logging.** Foreground `belt remote start` shows harness discovery and registration progress in the terminal and prints connection **warnings** there. Info-level WebSocket diagnostics are suppressed so they do not interleave with the spinner. When a harness session closes with an error, the daemon logs a warning such as `session for chat <id> closed: <reason>` — the same reason inference shows on the failed chat turn. A detached daemon writes info and warn logs to the file path shown by `belt remote status`; use `belt remote logs` or `belt remote logs -f` to read them.

With `--exec`, registration sends `exec_enabled: true` and the daemon accepts command-dispatch frames. With `--agents`, registration sends `agents_enabled: true` and the daemon hosts harness sessions the API opens over the socket (`remote_session_*` frames). Without each flag, the corresponding frames are refused on the host. Exec and agent sessions are independent opt-ins — you can enable either or both.

**Detached daemon.** `belt remote start --detach` keeps the socket open in the background. Use `belt remote status`, `belt remote logs -f`, and `belt remote stop` to manage it. `belt remote list` shows remotes registered to your team in the API.

**Connection state.** `belt remote status` follows the socket for as long as the daemon runs and shows the belt release the daemon runs (belt v1.28.6 and later). `state` is `connected` or `retrying`, with how long it has held, for example `retrying (for 54m12s)`. While it is retrying, `reason` carries the server's answer to the last attempt (`server answered 401 Unauthorized`) or the transport error, and `hint` names the fix when retrying alone will not get past it. The daemon logs the same lines on every change.

**Logins.** The daemon reads belt's credential again before every connection attempt, so it outlives the login it was started with. When a session expires or is replaced, the next reconnect is refused with `401`; run `belt login` and the daemon reconnects with the new login on its next attempt, within 30 seconds, without a restart. The team does not follow: the remote stays in the team that was active when it started, and `belt auth switch` in another terminal does not move it. On a server, an API key scoped to `remotes:read` and `remotes:write` (`belt login --key-stdin`, or `INFERENCE_API_KEY` in the daemon's environment) is not tied to a login session's lifetime.

With `--agents`, registration sends `agents_enabled: true` and the platform can list harness sessions on the machine and open chats that run the harness in a folder you choose. When a profile reports `capabilities.approvals: true`, risky tool calls wait for your approval in the workspace; harnesses with `approvals: false` (Pi today) run tools without asking. Without `--agents`, the status output shows `agents: disabled`.

### Start at boot

A remote started with `belt remote start` does not come back after a reboot. `belt remote enable` installs it as a service instead: a systemd user unit on Linux, a LaunchAgent on macOS.

```bash
belt remote enable --name build-box --exec --agents   # same flags as start
belt remote status                                     # mode: service, started at boot (login on macOS)
belt remote disable                                    # remove the service
```

`enable` writes a systemd **user** unit (`~/.config/systemd/user/belt-remote.service`), enables it, and starts it. No root is needed: the service runs as you, with your login and the `PATH` you enabled it from, so harness discovery finds the same CLIs your shell does. If a remote is already running on the machine, `enable` asks before restarting it as the service, because stopping it ends its agent sessions; decline and the service takes over at the next boot.

| Behaviour | Detail |
|-----------|--------|
| Boot | Starts when the machine boots. This needs lingering for your user; `enable` turns it on with `loginctl enable-linger`, and prints the `sudo` command to run when it is not allowed to. Without lingering the service starts at your first login. |
| Crash or failed start | Restarted after 15 seconds, for as long as it takes. A boot with no network yet is a failed start. |
| `belt remote stop` | Stops it until the next boot. A clean exit is not restarted. |
| `belt remote status`, `logs` | Work as for any remote. The service logs to the same file as a detached daemon. |
| Changing `--name`, `--exec`, `--agents` | Run `belt remote enable` again with the flags you want. |
| `belt remote disable` | Removes the unit. A remote running at that moment keeps running until you stop it. |

On macOS, `enable` writes `~/Library/LaunchAgents/sh.inference.belt-remote.plist` and loads it with `launchctl bootstrap`. Running `enable` again boots out the previous agent and waits up to 30 seconds for launchd to unload it before bootstrapping, so `bootstrap` does not fail with `5: Input/output error` (belt v1.28.6 and later). It behaves as on Linux, with one difference: a LaunchAgent starts when you log in, so a Mac comes back on its own after a reboot only with automatic login turned on (System Settings > Users & Groups). `belt remote stop` stops it until the next login, and `disable` marks it disabled and removes the plist.

Stopping or restarting the service ends the commands and agent sessions the daemon was running. A process a command deliberately left behind (a server started with `nohup`, for example) is not killed, the same as with a remote started by hand.

### List and remove remotes

Account-level commands (run from any machine where you are logged in):

```bash
belt remote list              # name, host, id, status, exec, last seen, belt version
belt remote rm <id>...        # remove stale rows from your account
belt remote rm <id> --yes     # skip the confirmation prompt
```

`belt remote list` marks the row for **this machine** when a local daemon is running. After a machine has been offline for **more than a week**, list prints a hint to remove it with `belt remote rm`.

`belt remote rm` calls `DELETE /remotes/{id}`. Use it for retired or reinstalled hosts that left an old registration behind. It is **destructive** (confirmation prompt, `--yes` in non-interactive mode). Removing a row does not stop a daemon that is still running on that host — run `belt remote stop` there first. If a stopped daemon starts again later, registration creates a **new** remote id (same machine key, fresh account row).

### Update belt on a remote

Update a connected remote to the latest belt from any machine, or with **update** on the remote's page:

```bash
belt remote update <id>
```

The remote stops taking new work and waits up to 10 minutes for running commands and agent turns to finish. It installs the release while still connected, then restarts the way it was started. It keeps its name and settings and reconnects on the new version. Idle agent sessions and open terminals end with the restart. If the install fails, the remote goes back to work on the version it has.

How it restarts depends on the platform:

| Platform | Restart |
|----------|---------|
| Linux, macOS | In the same process (same pid), so `belt remote status`, a terminal running it in the foreground and the systemd service all keep tracking it |
| Windows, detached | A new background process; `belt remote status` and `stop` follow it |
| Windows, foreground | The new version runs in the same console; the terminal's prompt returns when it ends |

A remote cannot update itself when belt was installed by a package manager (Homebrew, Scoop), or when the user it runs as cannot write to belt's directory. The answer says what to do on the machine instead.

#### When belt is updated on the machine

A remote also follows a belt installed over the one it runs, whatever installed it: `belt update`, belt's own auto-update, the install script or a package manager. It checks once a minute. Nobody asked for this restart, so it ends nothing: it waits until no command, agent turn or terminal is running, for as long as that takes, and keeps taking work meanwhile. Before it switches, it runs `belt --version` on the new file (no network); a file that does not run, or reports the release already running, is ignored. Then it restarts onto the installed belt the same way as above. Idle agent sessions resume on the next message.

`belt update` says when the remote on the machine will move. A remote started before belt v1.28.6 does not follow updates; `belt update` prints the command that restarts it once.

### Tags

**Tags** group remotes for workspace policy: a rule written for `tag:prod` applies to every machine that carries the `prod` tag, including machines you add later. Tags are lowercase letters, digits, `-`, `_`, or `.` (up to 40 characters each, at most 20 per remote). The API stores them normalized (trimmed, lowercased, deduplicated).

Changing tags needs **`manage_policy`** on the remote's workspace, because tags decide which policy rules apply to a machine. Unlike other remote writes, it does not require owning the remote.

```bash
belt remote tag <id>                  # show tags
belt remote tag <id> prod eu-west     # add tags (merged with existing)
belt remote tag <id> --rm eu-west     # remove tags
```

With `--json`, it prints the tag list after any changes. The REST equivalent is [`PUT /remotes/{id}/tags`](/docs/api/rest/remotes#set-remote-tags).

### System info

Registration and periodic heartbeats include a best-effort snapshot of the host in `system_info` (collected with gopsutil, the same approach private engines use):

| Field | Meaning |
|-------|---------|
| `hostname` | Machine hostname |
| `os` | OS label: the distro `PRETTY_NAME` on Linux, `macOS` on macOS, otherwise the Go OS name |
| `cpus` | One aggregate entry with `name`, `cores`, optional `frequency`, and `usage` (0–100% across all cores) |
| `ram.total`, `ram.used`, `ram.usage` | Physical memory in bytes and used percentage (0–100) |
| `volumes` | Optional. One entry for the filesystem that holds your home directory (where harness sessions and opened folders live): path, size, used, free, and `usage` percent |

A probe that fails reports a zero value. Registration does not fail because of it.

While the daemon stays connected, every **third** WebSocket heartbeat (~**15 seconds** at the default **5 second** interval) carries an updated `system_info`. The API stores each snapshot on the remote, so [GET /remotes/{id}](/docs/api/rest/remotes#get-remote) and the workspace remote page can show live load the way engine cards do. 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 HTTP heartbeat endpoint does not carry `system_info`.

### Harness discovery

The daemon probes the host using the [agentprotocol harness registry](https://github.com/inference-sh/agentprotocol) and sends the results in the `harnesses` field of `POST /remotes`. Each entry describes one registry harness whose binary was resolved on the machine (PATH, known install paths, or global package lists):

| Field | Meaning |
|-------|---------|
| `kind` | Registry id for the harness (for example `claude`, `codex`, `cursor`). Profiles also expose `display_name` and `vendor` for UI. |
| `command` | Resolved path to the harness binary |
| `version` | Harness version from discovery. Best effort, may be empty. |
| `logged_in` | `true` when the harness looks configured on this machine. It does not prove the harness can serve a run. |
| `capabilities` | What hosted sessions can do with the session driver belt would use for this harness (see below). Omitted when the harness cannot be driven from inference. |

Probes run **in parallel** for every registry harness. The daemon checks config directories, `PATH`, known install locations (for example `~/.local/bin`), and a cached global `npm list -g` (10 second timeout for the whole list). When a binary is found, it runs `<command> --version` with a **3 second timeout per harness**. A stalled or slow binary leaves `version` empty instead of blocking registration. A machine with several CLIs usually finishes **discovering agent harnesses...** in about a second before **registering remote...** appears.

Discovery uses the shared harness registry in [agentprotocol](https://github.com/inference-sh/agentprotocol) (`harness.DetectInstalled`). Every registry entry with a resolvable binary on the host is reported; `kind` is the registry id (for example `claude`, `codex`, `gemini`, `cursor`). Examples:

| `kind` | Typical binary | `logged_in` hint |
|--------|----------------|------------------|
| `claude` | `claude` | Harness config directory present (for example `~/.claude/`) |
| `codex` | `codex` | `~/.codex/` config present |
| `gemini` | `gemini` | `~/.gemini/` config present |
| `cursor` | `cursor-agent` | Cursor CLI config present |

`logged_in` means the harness looks configured on this machine (`Configured()` in the registry). It is not proof the harness can serve a run. Discovery never reads credential files or transmits tokens.

**Account and plan limits.** Registration does not wait for usage probes. Once connected, the daemon rediscovers its harnesses and runs each one's usage probe to read who it is signed in as and its plan's rate-limit windows (in parallel, 60 second timeout each). It sends the result on a heartbeat right after it connects and then every 5 minutes. A probe that reports `authenticated` or `unauthenticated` also sets `logged_in`. A failed probe keeps the last good windows. A running session can report newer numbers between probes.

The API exposes harnesses as **profiles** on each remote (`harness_kind`, `command`, `status`, `capabilities`, `account`, `limits`). On every `POST /remotes` the server reconciles the `harnesses` payload into profiles, one per harness kind. A harness with `logged_in: true` becomes `idle`; without it the profile is `unavailable`. If a harness disappears from the host, its profile stays and becomes `unavailable`.

**`capabilities` on a profile** mirror what the daemon reported at registration. The API branches on these flags instead of hard-coding harness names:

| Field | Meaning |
|-------|---------|
| `steer` | A prompt sent during a running turn can redirect it |
| `approvals` | The harness can pause on tool permission requests |
| `interrupt` | A turn can be cancelled without ending the session |
| `resume` | A session can be reopened by harness session id after the process exits |
| `tools` | The harness can call tools the API supplies at session open: the platform catalog tools `search_tools`, `list_tools` and `execute_tool`. See [Platform catalog tools](#platform-catalog-tools) |
| `images` | An image attached to a message reaches the harness's model as an image. When false, the harness gets the image's path on your machine and sees it only if it opens the file (belt v1.21.0+) |
| `files` | The daemon passes attached files to the harness (belt v1.21.0+). Older daemons do not report it, and get the files' URLs in the prompt text instead |

`capabilities` is omitted on older daemons or harnesses the session host cannot drive (no session driver in the registry); treat missing values as false.

Registration keys on the machine's `public_key` within your active **team** (from your API key or [`belt auth switch`](/docs/extend/cli-setup#switch-workspaces)). Restarting `belt remote start` on the same host updates that team's remote and its profiles; it does not create a duplicate. After you switch teams, the same machine gets a **separate** remote row for the new team. Remotes for other teams remain in the list until heartbeats go stale and the platform marks them `disconnected`, then `stopped`.

---

## Agent harness sessions

When `--agents` is enabled, the daemon hosts **one harness process per chat** the API opens on this machine. Inference owns the conversation, run lifecycle, and every permission decision; the daemon launches the harness, forwards events, and applies prompts, interrupts, and permission resolves the API sends.

**How harnesses are launched.** The session host picks a driver from the agentprotocol registry for each harness id:

| Harness | Driver | What runs on your machine |
|---------|--------|---------------------------|
| `claude` (Claude Code) | Native | Your `claude` binary over its stream-json control protocol |
| `codex` | Native | Your `codex` binary via `codex app-server` |
| Other harnesses with a session driver | ACP | The harness CLI in Agent Client Protocol mode |

Belt passes only the resolved binary path. It does not add registry auto-approve flags, pinned models, or endpoints. Tool permission prompts always reach inference so a person can allow or deny them. The harness uses **your** vendor login from its own config; credentials never pass through the daemon.

- **One chat, one session.** Re-opening the same chat attaches to the existing process instead of starting a second writer.
- **Open before launch.** `remote_session_open` reserves the chat slot before `remote_session_opened` is sent; the harness starts in the background so a prompt right behind the open waits on the session instead of racing the launch.
- **Event position on open.** `remote_session_opened` includes `seq`, the last event number the session has produced (`0` for a fresh process). Callers use it to tell a restarted harness from a replay after reconnect.
- **Replay buffer.** While the socket is down, the daemon keeps a bounded buffer of session events so the API can `replay` after reconnect.
- **Host limits.** The daemon caps concurrent harness sessions (default 8) even when a profile allows more.
- **Local session index.** Call [`GET /remotes/{id}/sessions`](/docs/api/rest/remotes#list-harness-sessions) (or the WebSocket `remote_sessions_list` frame) to see harness sessions on disk — metadata only: id, title, cwd, last updated, and whether a process currently holds the session. The daemon returns at most 500 sessions, newest first. When liveness is inferred rather than proven, `live.heuristic` is `true` (the workspace remote page marks those states with `?` in the UI).
- **Closing sessions.** When inference sends `remote_session_close`, or you stop the daemon (`Ctrl-C`, `belt remote stop`, or process exit), the daemon asks the harness to end. If the process is still running after a short grace period, belt kills it when the session driver supports that, so a wedged agent cannot hold a slot or block shutdown. Stopping the daemon ends all hosted sessions in parallel. ACP-backed harnesses also report closed when their CLI process exits on its own.

Harnesses without a session driver in the agentprotocol registry are not offered for hosted sessions (`capabilities` is omitted at registration).

### Questions from the harness

When a harness asks you a question instead of asking for permission, the chat shows it as a form: one choice (or several) per question, plus a field for an answer of your own. Submit, decline or cancel it like an MCP server's input request. Your answer goes back to the harness in its own format. No "always allow" rule or "allow every tool" setting answers a question for you (belt v1.28.4+).

| Harness | What asks |
|---|---|
| Claude Code | `AskUserQuestion` |
| Grok | `ask_user_question` (and leaving plan mode, which is asked as an approval of the plan) |
| ACP harnesses | `elicitation/create` |

Refusing the interrupt (instead of answering the form) tells the harness you declined.

### Background work

Some harnesses leave a command or a watch running while their turn goes on (Grok's background commands and monitors). The tool call that starts it completes at once. When the work ends, the chat shows a line saying what ended and how, for example `` `make build` failed (exit 2) ``.

If the harness then starts a turn of its own about it, that turn is a run of its own in the chat, answering that line, even though nobody sent a message. While your own run is still going, the harness's work is part of that run (belt v1.28.4+).

### Platform catalog tools

When a profile reports `capabilities.tools: true`, the API gives the harness the three [meta tools](/docs/api/agent/internal-tools#meta-tools) loop agents enable with `internal_tools.meta`: `search_tools`, `list_tools` and `execute_tool`. The harness keeps its own tools on the machine; these three reach the apps, agents, skills and knowledge in your workspace.

`remote_session_open` carries the tool definitions in `tools`. The daemon does not run them. When the harness calls one, the daemon sends `remote_session_tool_call` (`call_id`, `name`, `arguments`, `run_id`, optional `tool_invocation_id`). The API records the call on the chat as `type: internal`, runs it as that run, with lifecycle gates and [tool approval](/docs/api/agent/approval) as for any loop tool, and answers with `remote_session_tool_result` (`text`, `is_error`). Approvals for these calls are not sent with `remote_session_resolve`, which is only for tools the harness runs on your machine. A tool name outside the three is refused.

Profiles without `capabilities.tools` get no `tools` on open, and the harness sees only its own tools.

Wire format: [Remotes API — WebSocket protocol](/docs/api/rest/remotes#websocket-protocol).

### Attachments

Images and files you attach to a message in a harness chat go to the harness with the prompt (belt v1.21.0+).

1. The API sends the message's text and its files together in `remote_session_prompt`.
2. The daemon downloads each file into a temporary folder for that chat on your machine (https only, up to 50 MB and 60 seconds per file) and hands the harness a local path. The folder is deleted when the chat's session ends.
3. The harness gets each image as an image when it supports them (`capabilities.images`). Every other file, and every image for a harness without image support, is named in the prompt by its local path, so the harness can open it with its own tools.

| Gets images as images | Gets the image's path |
|---|---|
| Claude Code, Codex, Pi, Copilot, Droid, Gemini, Goose, Hermes, Kimi, Kiro, OMP, OpenCode, Qwen | Cursor, Grok, Kilo |

Some harnesses resize or re-encode an image before sending it to their model (OMP scales small images up, for example).

If a download fails, the harness gets the file's URL instead of a path. A daemon older than belt v1.21.0 does not report `capabilities.files`; for those, the API writes the files' URLs into the prompt text, so the harness still sees them. Update belt on the machine to get attachments as files.

Wire format: [Remotes API — WebSocket protocol](/docs/api/rest/remotes#websocket-protocol).

---

## Profile-backed agent chats

When you start the daemon with **`--agents`**, registration only exposes harness **profiles** on the remote. Workspace agents are created **when you launch** a harness from the app or API — not when the machine connects — so connecting a laptop does not fill your agent list.

**Launch from the workspace.** Pick a remote, harness profile, and folder; the UI calls `POST /remotes/{id}/sessions` and opens the returned chat. Optionally pick an existing harness session to resume (from sessions listed for that folder).

**Launch from the API.** Same endpoint — `profile_id`, `work_dir`, and optional `resume_session_id`. See [Launch harness chat](/docs/api/rest/remotes#launch-harness-chat).

The first launch for a profile creates one **harness agent** and reuses it afterward:

| Agent field | Value |
|-------------|--------|
| `title` | `{display name} · {remote name}` — for example `Claude Code · work-laptop` |
| `harness` | Agentprotocol registry id for the profile (`claude`, `codex`, …). `inference` is the default for template agents you deploy yourself. |
| `profile_id` / `remote_id` | Bound to that harness profile and remote |
| Versions | Harness agents do not use editable platform versions — instructions, tools, and skills come from the harness on your machine. |

Each **chat** carries the folder in `work_dir` and optional `harness_session_id` when you resume. Harness sessions are folder-bound; the agent record is not.

Send messages with `POST /agents/run`, `POST /chats/{id}/messages`, the SDK, or the workspace. The API opens the harness session on your remote in that folder, streams replies into the chat, forwards permission prompts as approval interrupts, and records harness-side tool calls without re-running them on inference.sh. Harnesses that report `capabilities.steer: true` (for example Claude Code and Codex) accept follow-up user messages during an in-progress turn; others queue them for the next turn.

**Requirements:** the remote must stay connected with `--agents`, the profile must be `idle` with valid `capabilities`, and the harness must remain logged in on the host. See [Agents API — Remote harness runs](/docs/api/rest/agents#remote-harness-runs) for run lifecycle, queueing, stop, and error strings.

**Disconnect.** If the daemon drops while a harness run is active, the run fails with `the remote disconnected` and is not retried. Restart `belt remote start --agents` and send a new message to continue the conversation (the chat keeps `harness_session_id` when the harness can resume the session).

---

## Run a command

Dispatch a command to a connected remote and stream its output:

```bash
belt remote exec <remote-id> -- ls -la
belt remote exec <remote-id> -- bash -c "echo hi && uname -a"
```

| Flag | Purpose |
|------|---------|
| `--cwd <path>` | Working directory on the remote |
| `--timeout <seconds>` | Kill the command after N seconds (`0` = no limit) |
| `--pty` | Run under a pseudo-terminal |

The remote must have been started with `belt remote start --exec`. Otherwise the API returns `403 exec_disabled`.

`belt remote exec` is marked **destructive** and prompts for confirmation before dispatching. In CI or scripts, pass `--yes` / `-y`, or call the REST API directly.

**Exit behavior.** belt exits with the remote command's exit code when the run status is `exited`. Runs that end as `killed` or `denied` print an error and exit `1`.

**Stopping a command.** Pressing **Ctrl-C** in `belt remote exec` kills the command on the remote and waits for the run to end; a second Ctrl-C exits without waiting. To kill a run started elsewhere (by an agent's `remote_exec`, or through the API), use its run id:

```bash
belt remote kill <run-id>...      # confirms first; --yes to skip
```

The run id is the `exec_run_id` in a `remote_exec` result, or the `id` of a run from the [Exec Runs API](/docs/api/rest/exec-runs). `belt remote kill` reports `killed <id>` once the remote confirms the run ended, and says so when it does not.

A kill, like a timeout, ends the **whole process tree**. Each run gets its own process group, and the daemon kills the group, so what `sh -c "build && serve"` started dies with the shell. This is why a run id is enough: there is no need for `pkill -f`, which matches on command text and also matches the shell that runs it. A process that left the group on purpose (`setsid`, a daemonizing server) survives; the run still ends, two seconds after the kill at the latest.

**Disconnects.** A short network drop marks the remote `disconnected` but does **not** kill commands already running on the host. Runs fail when the remote stays disconnected long enough to reach `stopped` (about **30 seconds**) or when you signal them. Start the daemon again before that grace ends if you need long-running execs to survive a laptop sleep or Wi‑Fi outage.

---

## REST API

For automation, use the exec and session APIs:

1. `POST /remotes/{id}/execs` creates a run
2. `GET /execs/{id}/output?after_seq=N` returns sequenced stdout/stderr chunks
3. `GET /execs/{id}` returns the run status
4. `POST /execs/{id}/signal` cancels a running exec
5. `GET /remotes/{id}/sessions` lists harness sessions on a connected remote with `--agents` (see [List harness sessions](/docs/api/rest/remotes#list-harness-sessions))
6. `POST /remotes/{id}/sessions` opens a new harness chat in a folder (see [Launch harness chat](/docs/api/rest/remotes#launch-harness-chat))

These calls need the `remotes:read` and `remotes:write` scopes; launching a harness chat also needs `agents:execute`. Full reference: [Remotes API](/docs/api/rest/remotes) and [Exec Runs API](/docs/api/rest/exec-runs).

---

## From agents

Agents can run commands on connected remotes when you enable the **Remotes** internal tool category (`internal_tools.remote: true`). The runtime exposes `remote_list` and `remote_exec` — the same exec-run domain as the REST API and `belt remote exec`, with the remote's `--exec` opt-in and API scopes still enforced. `remote_list` reports each remote's `exec_enabled` flag (whether the daemon started with `--exec`).

When inference drives a coding harness on your machine (a chat bound to a remote profile), the remote must be connected with `--agents` as well. Shell commands from the **Remotes** internal tools still require `--exec` only.

`remote_exec` completes asynchronously: the agent turn waits on the tool invocation until the exec run finishes (like an app tool waiting on its task). The run also appears as an execution node in the chat trace. Stopping the chat signals in-flight exec runs through the same graph sweep that cancels app tasks. If the remote disconnects before the command ends, the run becomes `killed` with `error` `remote disconnected before the command finished` so the turn does not hang.

→ [Internal Tools: Remote tools](/docs/api/agent/internal-tools#remote-tools)

---

## Troubleshooting

| Symptom | Likely cause |
|---------|--------------|
| `not logged in` | Run `belt login` or set `INFERENCE_API_KEY` |
| `agents_disabled` | Restart the daemon with `belt remote start --agents` |
| `exec_disabled` | Restart the daemon with `belt remote start --exec` |
| `remote_offline` | The daemon is not connected. Run `belt remote start` on the host. |
| `belt remote status` shows `retrying` with `server answered 401 Unauthorized` | The login the daemon holds expired or was replaced. Run `belt login` on that machine; the daemon reconnects on its next attempt (within 30 seconds). |
| `belt remote status` shows `retrying` with `server answered 403 Forbidden` or `404 Not Found` | The current login may not use this remote (a different account), or the remote was removed with `belt remote rm`. Log in as the account that started it, or `belt remote stop` and start again to register it. |
| The remote is gone after a reboot | It was started by hand. Run `belt remote enable` with the same flags to start it at boot. |
| `belt remote enable` on macOS fails with `5: Input/output error` | launchd was still unloading the previous agent. Run `enable` again, or upgrade belt: v1.28.6 and later wait for the unload. |
| The remote still runs an old belt after `belt update` on the machine | From belt v1.28.6 it moves once nothing is running. A remote started before that release needs one restart; `belt update` prints the command. |
| `starting at boot needs systemd` | `belt remote enable` supports Linux with systemd. Elsewhere, use `belt remote start --detach` and your platform's own service manager. |
| `a remote is already running on this machine` | A start with the same `--exec` / `--agents` as the running daemon. Run `belt remote status`, then stop or disconnect that session, or start with different flags to get a restart prompt. |
| `left the running remote as it is; 'belt remote stop' then start again` | You declined restarting to change `--exec` or `--agents`, or confirmation failed in non-interactive mode. Run `belt remote stop`, then `belt remote start` with the flags you need. |
| Unknown flag on `belt remote --exec` | Pass flags to `belt remote start` (the error repeats your flags with `start` in the command). |
| `a remote daemon is already running` | Same as above when starting with `--detach` directly. |
| `harness_not_drivable` | The harness is installed but the daemon cannot drive it (no `capabilities` on the profile) |
| Launcher card shows **too old** | The harness CLI on the host is below the session driver's minimum. Run `support.upgrade_cmd` on the machine (shown on the card), then restart `belt remote start` so discovery picks up the new version. |
| Launcher card is **ready** with a yellow version warning | The installed build is outside inference's tested range or could not be matched to the registry. You can still launch; upgrade when convenient if you hit issues. |
| Harness profile shows **idle** but launch or chat fails with a sign-in error | The harness CLI on the host is signed out. Sign in there (for example `claude` login), then restart `belt remote start` so enrollment re-runs discovery. Keyring-only harnesses can look idle when only the config directory exists; file-based harnesses need the registry login file (for example `~/.codex/auth.json`). **Cursor:** export `CURSOR_API_KEY` in the same environment as the remote daemon (systemd unit, container env, or shell) so discovery sees key-only auth; a bad or revoked key can still fail on the first turn. |
| Chat on **thinking** for ~1 minute, then a failed turn | The harness did not finish **opening** within 60 seconds — often blocked on sign-in or an update in the terminal. Run the same CLI locally in the project folder, complete any prompts, then send again. |
| Turn failed: *did not start a session within* | Same as above: the harness CLI on the host must reach a drivable state within 60 seconds of open. |
| Turn failed after ~**2 minutes** with *no message, tool call, permission request or response* / `no_response` | The harness accepted the prompt but never showed a sign of work (stuck starting MCP servers, waiting on a backend, or wedged). Read the **`agent stderr`** tail in the error or `belt remote logs`, fix the CLI on the host, then send again. |
| Long coding turn used to stop around **10 minutes** | Older belt builds capped whole ACP prompts at 10 minutes. Current belt (agentprotocol **v0.12.1+**) only bounds time **until the first sign of work** (2 minutes); after that the turn runs until the harness finishes. Upgrade belt on the machine running `belt remote start`. |
| Turn failed: *no session for chat* / *session ended* / *failed to open* | The daemon no longer holds that chat's session (restart, crash, concurrent session limit, or a prior open failure). Start a **new session** from the remote page or launch a fresh chat. |
| `remote_timeout` on session list | The daemon did not answer within 10 seconds — check connectivity |
| Session list error right after `belt remote` restart | Heartbeat can mark the remote `running` before the WebSocket is back. Wait a few seconds or click **refresh**; the page retries listing automatically. |
| **discovering agent harnesses...** for more than ~15 seconds | A hung `npm list -g` or many harnesses timing out on `--version`. Upgrade belt (agentprotocol v0.10.2+), or run `npm list -g` manually to fix npm. |
| Harness session closes with `agent sessions are disabled on this remote` | Restart the daemon with `belt remote start --agents` |
| Run ends as `denied` | The API could not deliver the command to the daemon |
| Run ends as `killed` with `command execution is disabled on this remote` | The daemon refused the run. Restart it with `--exec`. |
| Run ends as `killed` with `remote disconnected before the command finished` | The remote missed heartbeats long enough to go `disconnected` or `stopped` while the command was still running. Restart `belt remote start` and dispatch again. |
| Agent run fails with `the remote disconnected` | Same disconnect sweep while a profile-backed harness turn was in progress. The turn is not retried; send a new message after the daemon reconnects. |
| Agent run fails with `this remote does not run agent sessions` | Start the daemon with `belt remote start --agents`. |
| Agent run fails with `the harness this agent runs on is not available` | Harness logged out, profile `unavailable`, or missing session `capabilities` — fix login on the host and reconnect the daemon. |
| No output | Poll `/execs/{id}/output` with an increasing `after_seq` cursor |

---

## Next

→ [CLI setup](/docs/extend/cli-setup#remotes-cli): command table and automation notes

→ [Remotes API](/docs/api/rest/remotes): register, list, update, and delete remotes

→ [Exec Runs API](/docs/api/rest/exec-runs): dispatch commands and read output
