---
title: "Login and the CLI"
description: "How the CLI and an MCP client get a token (a device code that someone with a console login approves), and the CLI releases it updates itself from."
order: 6
---

How the CLI and an MCP client get a token (a device code that someone with a console login approves), and the CLI releases it updates itself from.

## `POST /api/v1/auth/device`

Start a device grant.

**Body:** [DeviceAuthorizeBody](#deviceauthorizebody)

| Status | Means | Body |
| --- | --- | --- |
| 200 | Done | [DeviceAuthorizeResponse](#deviceauthorizeresponse) |
| 422 | client must be cli or mcp |  |
| 429 | Too many grants started from this address |  |

## `POST /api/v1/auth/device/token`

Exchange a `device_code` for the token the approver authorized.

**Body:** [DevicePollBody](#devicepollbody)

| Status | Means | Body |
| --- | --- | --- |
| 200 | Done | [DeviceTokenResponse](#devicetokenresponse) |
| 400 | authorization_pending, slow_down, expired_token, access_denied or unsupported_grant_type | [DeviceErrorResponse](#deviceerrorresponse) |

## `GET /api/v1/auth/device/{user_code}`

What is being approved, plus every role the approver could approve it with.

| Parameter | In | Type | About |
| --- | --- | --- | --- |
| `user_code` | path | string |  |

| Status | Means | Body |
| --- | --- | --- |
| 200 | Done | [DeviceGrantResponse](#devicegrantresponse) |
| 401 | No token, or a token that is not valid |  |
| 404 | Not found |  |
| 429 | Too many code lookups from this address |  |

## `POST /api/v1/auth/device/{user_code}/approve`

Approve the device, with the role, the pin and the expiry the token will carry. 204: the secret goes to the polling client and to nobody else, the approver included.

| Parameter | In | Type | About |
| --- | --- | --- | --- |
| `user_code` | path | string |  |

**Body:** [DeviceApproveBody](#deviceapprovebody)

| Status | Means | Body |
| --- | --- | --- |
| 204 | Done, with no body |  |
| 401 | No token, or a token that is not valid |  |
| 403 | A machine operator cannot approve a device |  |
| 404 | Not found |  |
| 409 | Already decided |  |
| 422 | A token cannot carry more than the approver does |  |

## `POST /api/v1/auth/device/{user_code}/deny`

Deny the device. No body: there is nothing to decide but no.

| Parameter | In | Type | About |
| --- | --- | --- | --- |
| `user_code` | path | string |  |

| Status | Means | Body |
| --- | --- | --- |
| 204 | Done, with no body |  |
| 401 | No token, or a token that is not valid |  |
| 404 | Not found |  |
| 409 | Already decided |  |

## `POST /api/v1/cli/install-codes`

Mint a single use install code for the caller, in one project.

**Body:** [InstallCodeBody](#installcodebody)

| Status | Means | Body |
| --- | --- | --- |
| 200 | Done | [InstallCodeResponse](#installcoderesponse) |
| 401 | No token, or a token that is not valid |  |
| 403 | A machine, or an operator with no role here |  |
| 404 | No such project |  |
| 422 | A token cannot carry more than the caller does |  |

## `GET /api/v1/cli/install.sh`

Redeem an install code and answer the installer for this machine.

| Status | Means | Body |
| --- | --- | --- |
| 200 | The installer, holding a freshly minted token | `text/x-shellscript` bytes |
| 401 | No code, or one that is used, expired or unknown |  |
| 503 | Nothing is published to install yet |  |

## `GET /api/v1/cli/release`

The newest CLI release for one platform, which `ops update` compares itself with.

| Parameter | In | Type | About |
| --- | --- | --- | --- |
| `target` | query | string | ${platform}-${arch}, node's spelling |

| Status | Means | Body |
| --- | --- | --- |
| 200 | Done | [CliReleaseResponse](#clireleaseresponse) |
| 401 | No token, or a token that is not valid |  |
| 403 | Your role does not allow this |  |
| 404 | Nothing published for that target yet |  |
| 422 | Not a target ops publishes for |  |

## `GET /api/v1/cli/releases/{version}/{target}/download`

The CLI binary of one release for one platform.

| Parameter | In | Type | About |
| --- | --- | --- | --- |
| `version` | path | string |  |
| `target` | path | string |  |

| Status | Means | Body |
| --- | --- | --- |
| 200 | The binary |  |
| 401 | No token, or a token that is not valid |  |
| 403 | Your role does not allow this |  |
| 404 | No such release |  |
| 422 | Not a target ops publishes for |  |

## Types

### DeviceAuthorizeBody

POST /api/v1/auth/device, the request body. Everything is optional: a bare `{}` is a valid device authorization request.

| Field | Type | About |
| --- | --- | --- |
| `client` (optional) | string or null | `cli` (default) or `mcp`. |
| `client_name` (optional) | string or null | What the client calls itself, shown to the approver. Never trusted, and truncated to 80 characters. |
| `project` (optional) | string or null | The project the client would like the token pinned to. The approver decides. |

### DeviceAuthorizeResponse

| Field | Type | About |
| --- | --- | --- |
| `device_code` | string |  |
| `expires_in` | integer |  |
| `interval` | integer |  |
| `user_code` | string | Display form, `XXXX-XXXX`. |
| `verification_uri` | string |  |
| `verification_uri_complete` | string |  |

### DevicePollBody

POST /api/v1/auth/device/token, the poll body.

| Field | Type | About |
| --- | --- | --- |
| `device_code` | string |  |
| `grant_type` (optional) | string or null |  |

### DeviceTokenResponse

The poll's success body. `scope` is space separated, the OAuth convention, and holds the token's snapshotted permission keys.

| Field | Type | About |
| --- | --- | --- |
| `access_token` | string |  |
| `expires_in` (optional) | integer or null | Seconds until the token expires; null when the approver chose no expiry. |
| `operator` | string | Who approved it, and therefore who it acts as. |
| `project` (optional) | string or null | The slug the token is pinned to, null when it acts wherever its operator may. |
| `role` | string |  |
| `scope` | string |  |
| `token_type` | string | Always `Bearer`. |

### DeviceErrorResponse

One field, and the five values are the whole vocabulary this route answers with.

| Field | Type | About |
| --- | --- | --- |
| `error` | string |  |
| `error_description` | string | A whole sentence for a human, which the CLI prints under the code. |

### DeviceGrantResponse

GET /api/v1/auth/device/{user_code}, what the approval page renders.

| Field | Type | About |
| --- | --- | --- |
| `client` | string | cli \| mcp |
| `client_name` | string | "" when the client named nothing. |
| `created_at` | string |  |
| `default_project` (optional) | string or null |  |
| `default_role_id` (optional) | string (uuid) or null |  |
| `expires_at` | string |  |
| `ip` | string |  |
| `requested_project` (optional) | string or null |  |
| `roles` | array of [DeviceRoleOption](#deviceroleoption) | The page preselects `default_role_id`. |
| `status` | string | pending \| approved \| denied \| expired |
| `user_code` | string | Display form. |

### DeviceApproveBody

POST /api/v1/auth/device/{user_code}/approve

| Field | Type | About |
| --- | --- | --- |
| `expires_in_days` (optional) | integer or null | Days until expiry. Omitted = `OPS_TOKEN_TTL_DAYS` (90), 0 = no expiry. |
| `name` (optional) | string or null | Shown on the operator's Tokens list. Omitted = "ops CLI on {client_name}". |
| `project` (optional) | string or null | null = the token acts wherever the approver may; a slug pins it. |
| `role_id` | string (uuid) | The role the minted token snapshots. Required: there is no default role anywhere in this codebase and there is not one here either. |

### InstallCodeBody

| Field | Type | About |
| --- | --- | --- |
| `project` | string | The project whose Develop page the line was copied on. |

### InstallCodeResponse

| Field | Type | About |
| --- | --- | --- |
| `code` | string | `opsi_…`, for the install line's `Authorization` header. Shown once and never stored: only its digest is. |
| `expires_in` | integer | Seconds until the code stops working. |
| `role` | string | The role the machine's token will carry, for the line under the snippet. |
| `token_project` (optional) | string or null |  |

### CliReleaseResponse

The answer to "is the binary running this command still the one ops serves". `download` is a PATH and not an absolute url, deliberately: the CLI already holds the base url it authenticated against, and a url built from a server side `base_url` would send a client that reached ops through one name to another one, where its token may not even be presented.

| Field | Type | About |
| --- | --- | --- |
| `digest` | string | Lowercase hex SHA-256 of the binary. The CLI verifies the download against this BEFORE it replaces its own executable. |
| `download` | string | The path to fetch, `/api/v1/cli/releases/{version}/{target}/download`. |
| `published_at` | string | Which release is current is decided by this, never by the number. |
| `size_bytes` | integer |  |
| `target` | string | `${platform}-${arch}`, echoed back so a client can see it was understood. |
| `version` | string | The version the CLI will report for itself once it has updated. |

### DeviceRoleOption

| Field | Type | About |
| --- | --- | --- |
| `id` | string (uuid) |  |
| `mintable` | boolean | Advisory: approve runs the real intersection again. |
| `name` | string |  |
| `project` (optional) | string or null |  |
