# Control

/v1/machines/{machineId}/control — open, run code on, interrupt and close direct control of a machine.

The endpoints behind [direct control](/documentation/concepts/direct-control) (research preview). Control is a lease on one machine, so it lives under the machine:

```
https://api.simular.ai/v1/machines/{machineId}/control
```

`machineId` is one of your cloud computers from [`GET /v1/agents/machines`](/documentation/api-reference/machines). You hold at most one lease per machine, so there is no separate id: the machine is the handle.

Every call carries `Authorization: Bearer sapi_...`, as everywhere else. See [Authentication](/documentation/api-reference/authentication).

## Open

`POST /v1/machines/{machineId}/control`

No body.

```json
{ "machineId": "m_7f2a", "expiresAfterIdleSec": 600 }
```

Answers `201` once the lease holds the computer. If a Sai task holds it, or the computer has to start, that can take up to three minutes. If you already hold an open lease on the machine, it answers `200` at once with the same body.

A lease whose computer stopped answering (for example, the service behind it was restarted abruptly) still looks open to this call. The first exec that gets no answer closes it with `410 control_closed` and reason `unresponsive`, and the next open starts a fresh lease.


## Exec

`POST /v1/machines/{machineId}/control/exec`

```json
{
  "code": "var r = await machine.exec({ command: 'hostname' }); r.stdout.trim()",
  "timeoutMs": 60000
}
```

| Field       | Required | Notes                                                                |
| ----------- | -------- | -------------------------------------------------------------------- |
| `code`      | yes      | Simulang (JavaScript). Up to 200,000 characters.                     |
| `timeoutMs` | no       | How long the block may run. Default 120,000, min 1,000, max 300,000. |

Any other field, or a value out of range, answers `400 invalid_body`.

```json
{
  "result": "WIN-CLOUD-7F2A",
  "logs": [],
  "observations": {},
  "variables": [
    { "name": "r", "type": "object", "preview": "{ stdout: 'WIN-CLOUD-7F2A\\r\\n', … }" }
  ],
  "durationMs": 412
}
```

| Field                     | Notes                                                                                                                                                        |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `result`                  | The block's last expression as JSON. A snapshot comes back as its outline text. Over 256 KB, it is truncated to a string.                                    |
| `logs`                    | Console output, oldest first. Up to 200 lines of 2,000 characters.                                                                                           |
| `error`                   | Present when the block did not finish: `{ kind, message }`.                                                                                                  |
| `observations.screenshot` | `{ base64, mimeType }`. Present only when this block captured the screen. If it was too large to return, `observations.screenshotOmitted` is `true` instead. |
| `variables`               | `[{ name, type, preview }]` for every variable the REPL holds.                                                                                               |
| `durationMs`              | How long the block ran.                                                                                                                                      |

A block that throws is still a `200`. Read `error.kind`:

| `kind`        | Meaning                                                                                                                |
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `exception`   | The code threw. Variables set before the throw are kept.                                                               |
| `timeout`     | The block ran past `timeoutMs` and was stopped, or it waited past `timeoutMs` behind an earlier block and did not run. |
| `interrupted` | You called interrupt.                                                                                                  |

A stopped block normally keeps the REPL's variables. A block that does not stop within a few seconds of a timeout or interrupt is ended by restarting the REPL, and the earlier variables are gone.

Blocks run one at a time, in the order they arrive. A second exec sent while one is running waits for it, and that wait counts toward its own `timeoutMs`.

If the lease has ended, exec answers `410 control_closed` with a `reason`:

| `reason`       | Meaning                                                                                              |
| -------------- | ---------------------------------------------------------------------------------------------------- |
| `closed`       | You closed it.                                                                                       |
| `idle_timeout` | It sat idle and released the computer.                                                               |
| `lock_lost`    | A Sai task took the computer while the lease was quiet.                                              |
| `unresponsive` | The computer did not answer within the block's `timeoutMs` plus 30 seconds, so the lease was closed. |
| `shutdown`     | The service behind the lease restarted.                                                              |

```json
{
  "error": "control_closed",
  "message": "The computer stopped answering, so this control lease was closed. Open a new one.",
  "reason": "unresponsive"
}
```

Open control again for a fresh lease. With no lease on the machine at all, exec answers `404 control_not_open`.

## Interrupt

`POST /v1/machines/{machineId}/control/interrupt`

Stops the running block at its next step or loop iteration. Its exec answers with `error.kind: "interrupted"`.

```json
{ "interrupted": true }
```

`false` means nothing was running. Answers `202`.

## Get

`GET /v1/machines/{machineId}/control`

Your latest lease on the machine, open or closed.

```json
{
  "machineId": "m_7f2a",
  "status": "open",
  "createdAt": 1791273600000,
  "lastExecAt": 1791273712000,
  "expiresAfterIdleSec": 600
}
```

A closed lease has `status: "closed"` and a `closedReason`, one of the exec `reason` values above. `createdAt` and `lastExecAt` are milliseconds since the epoch; `lastExecAt` is `null` before the first block. A lease that failed to open never replaces the one shown here. `404 control_not_open` if you never opened control on the machine.

## Close

`DELETE /v1/machines/{machineId}/control`

Clears the REPL and releases the computer. Answers `204`, also when no lease is open. Always close when you are done: until then, Sai's tasks on that computer wait.

## Errors

Error bodies are `{ "error": "<code>", "message": "..." }`.

| Status | `error`                                                 | When                                                                                     |
| ------ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `400`  | `invalid_machine`                                       | The `machineId` in the path is malformed.                                                |
| `400`  | `machine_not_supported`                                 | The machine is your own desktop. Control runs on cloud computers.                        |
| `400`  | `invalid_body`                                          | Exec: a missing or unknown field, or a value out of range. `details` lists the problems. |
| `403`  | `forbidden`                                             | Open: the computer refused this account.                                                 |
| `403`  | `phone_verification_required`, `phone_allowance_in_use` | Free plan: verify a phone number in the Sai app first.                                   |
| `404`  | `machine_not_found`                                     | Not yours, or does not exist.                                                            |
| `404`  | `control_not_open`                                      | No lease on this machine. Open one first.                                                |
| `409`  | `machine_busy`                                          | A Sai task or another lease holds the computer. Honor `Retry-After` when set.            |
| `409`  | `not_free_tier`                                         | Open on the free computer, but the account is no longer on the free plan.                |
| `410`  | `control_closed`                                        | The lease has ended; `reason` says why (see [Exec](#exec)). Open control again.          |
| `429`  | `budget_exhausted`                                      | Free plan: today's computer time is used up. Resets at midnight UTC.                     |
| `429`  | `rate_limited`                                          | More than 30 leases opened, or 3,000 blocks run, in an hour.                             |
| `502`  | `dispatcher_unavailable`                                | Open: Sai could not reach the computer. Retry shortly.                                   |
| `503`  | `machine_unavailable`, `no_capacity`                    | Open: the computer did not come online in time. Honor `Retry-After`.                     |

## Gotchas

- `var`, not `let` or `const`, for anything you want in the next block.
- Screenshots are base64 in the JSON. Decode before saving; do not log them.
- The free plan's computer is deleted when the lease closes: copy out anything you need first.
- Every exec counts as activity. A lease you stop using releases the computer after 10 minutes, or 2 when a Sai task is waiting for it.
