> ## Documentation Index
> Fetch the complete documentation index at: https://docs.2501.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# API Overview

> Drive 2501 as code over the versioned /api/v1 HTTP API

**`/api/v1`** on your Command Center host is the stable, documented HTTP API: what you script against to drive 2501 from a pipeline, a CMDB sync, or a shell script. It is a real contract, not a private back end that happens to be reachable - Command Center's own hosts and agents screens are built on these same endpoints.

```bash theme={null}
curl "https://<your-command-center-host>/api/v1/hosts?org_id=<org-id>" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
{
  "object": "list",
  "data": [{ "id": "hst_8272f9b4-...", "name": "web-01" }],
  "has_more": false
}
```

A call that returns `200` is how you confirm a key works. What the key can reach is fixed at creation - see [Authentication](#authentication).

## What is in the API

| Resource   | Endpoints                                                                                                    |
| ---------- | ------------------------------------------------------------------------------------------------------------ |
| **Hosts**  | list, search, read, create, update, delete, the agents on a host, bulk import and export                     |
| **Agents** | list, search, read, create, update, archive, its tasks, its plugins, connection test, bulk import and export |

Every endpoint - with its request and response schema and a live request builder - is in the **API Reference** in the sidebar.

<Note>
  Other things Command Center manages - credentials, specialties, operational rules, tickets, jobs, knowledge - are still served from unversioned routes that exist for the UI and are **not** part of the `v1` contract yet. They can change in any release. Resources move under `/api/v1` release by release, and only what is documented here is stable.
</Note>

## Authentication

Two ways in, and every `/api/v1` endpoint accepts either:

* An **API key** as a bearer token. This is the one for scripts and integrations.
* A **Command Center session cookie**. This is what the web UI uses.

Generate a key in **Settings** → **API Keys**, or read [API Keys](/0.12/configure/api-keys) for the full walkthrough of scopes, expiry, and revocation. The raw key is shown once, at creation.

```bash theme={null}
curl "https://<your-command-center-host>/api/v1/hosts?org_id=<org-id>" \
  -H "Authorization: Bearer 2501_ak_..."
```

A few things worth knowing before you script against it:

* **A key is an administrator inside its own scope.** Full read and write on its organization, or on every organization in the tenant for a tenant-scoped key. That reach is frozen at creation: it does not follow the person who created it.
* **A key never reaches anything outside `/api/v1`.** The bearer header is ignored elsewhere, and the CC-only `/api/internal` routes reject it outright. Notably, a key cannot create, list, or revoke keys - that stays session-only, so a leaked key cannot mint a successor.
* **A bad key is a hard failure, never a downgrade.** An invalid, revoked, or expired key gets `401 UNAUTHORIZED`; it never falls back to an anonymous request.

<Warning>
  Treat a key as a password. It is a full administrator within its scope, and it does not expire unless you asked it to.
</Warning>

## Organizations

Hosts and agents belong to exactly one organization, so **anything addressing a collection has to name one**:

* reading a collection (`GET /hosts`, `/hosts/search`, `/hosts/export`) carries `org_id` in the query string,
* creating (`POST /hosts`) carries `org_id` in the body, or in the query string for a CSV `batch`,
* anything addressed by id - a read, an update, a delete, an action, a sub-resource - carries none. The id already fixes the organization.

Omitting it where it is required is a `400`. Naming one you cannot reach fails in one of two ways, and the difference is deliberate: an **org-scoped** caller pointing anywhere outside its own organization gets `403 ORG_ACCESS_DENIED`, while a **tenant-scoped** caller naming an organization that is not in its tenant gets `404 NOT_FOUND` - another tenant's organizations are invisible, not merely forbidden.

```bash theme={null}
# The organization id is on the Organizations page in Command Center,
# and in the URL of any of its resources.
curl "https://<host>/api/v1/agents?org_id=org_460b541c-..." \
  -H "Authorization: Bearer $API_KEY"
```

## Reading lists

Every list endpoint answers with the same envelope:

```json theme={null}
{
  "object": "list",
  "data": [{ "id": "hst_8272f9b4-...", "name": "web-01" }],
  "has_more": false
}
```

Pages are **cursors**, not page numbers. Ask for `limit` rows, then pass the **id of the last row you got** as `starting_after` to get the next page. `has_more` tells you when to stop.

| Parameter             | Description                                                                                                        |
| --------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `limit`               | Rows per page. Default 25, maximum 100                                                                             |
| `starting_after`      | Id of the last row of the previous page. Walks forward                                                             |
| `ending_before`       | Id of the first row of the previous page. Walks backward. Cannot be combined with `starting_after`                 |
| `include=total_count` | Adds `total_count` to the envelope. Left out otherwise, because most callers iterating a list never need the count |

```bash theme={null}
# Walk a fleet, 100 at a time.
curl "https://<host>/api/v1/hosts?org_id=<org>&limit=100" -H "Authorization: Bearer $API_KEY"
curl "https://<host>/api/v1/hosts?org_id=<org>&limit=100&starting_after=hst_8272f9b4-..." \
  -H "Authorization: Bearer $API_KEY"
```

A cursor names a row rather than a position, so a walk is safe while other people are writing: nothing gets skipped or served twice because rows were inserted above you. There is no `page` parameter anywhere in `v1`. A cursor id that does not resolve - a deleted row, a row in another organization - is a `400 INVALID_CURSOR` rather than a silent jump back to page one.

## Writing

**`POST` creates and `POST` updates.** `POST /api/v1/hosts` creates a host; `POST /api/v1/hosts/{id}` updates that one. There is no `PUT` in `v1`.

An update is a **partial** update, and the rules are the same everywhere:

| In the body             | Effect                               |
| ----------------------- | ------------------------------------ |
| field absent            | left unchanged                       |
| field with a value      | set to that value                    |
| field explicitly `null` | cleared, where the field is nullable |

```bash theme={null}
# Only knowledge changes. Everything else on the host is untouched.
curl -X POST "https://<host>/api/v1/hosts/hst_8272f9b4-..." \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"knowledge": "Runs the billing API. Restart with systemctl restart billing."}'
```

Verbs that are not create-read-update-delete are `POST` actions on an item: `POST /api/v1/agents/{id}/archive`, `POST /api/v1/agents/{id}/test-connection`.

**Success is always `200`**, creates included. There is no `201` and no `204`.

## Errors

Every error, on every endpoint, has the same flat body:

```json theme={null}
{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed",
  "request_id": "req_3702b7e7-...",
  "errors": [{ "field": "org_id", "message": "org_id field has an invalid format" }]
}
```

| Field        | When                                                                                      |
| ------------ | ----------------------------------------------------------------------------------------- |
| `code`       | Always. Branch on this, not on `message`                                                  |
| `message`    | Always. Human-readable, and free to be reworded                                           |
| `request_id` | Always, and repeated in the `X-Request-Id` response header. Quote it in a support request |
| `field`      | When one input is at fault                                                                |
| `errors`     | When several are. Every failing field at once, not just the first                         |
| `details`    | Extra context for some codes, such as the id that was not found                           |

The codes you will actually meet:

| Status | `code`                                                                  | Meaning                                                                                                                            |
| ------ | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION_FAILED`                                                     | A field is missing, malformed, or not allowed. See `errors`                                                                        |
| 400    | `BAD_REQUEST`                                                           | The request is well-formed but wrong, for example an unknown host tag                                                              |
| 400    | `INVALID_CURSOR`                                                        | `starting_after` / `ending_before` names a row that is not there                                                                   |
| 401    | `UNAUTHORIZED`                                                          | No credentials, or a key that is invalid, revoked, or expired                                                                      |
| 403    | `FORBIDDEN`                                                             | Authenticated, but not allowed - including an auditor attempting a write                                                           |
| 403    | `ORG_ACCESS_DENIED`                                                     | The organization is real but out of this caller's reach                                                                            |
| 403    | `LICENSE_CAP_REACHED`, `LICENSE_EXPIRED`                                | The write would exceed the licensed host count, or the licence is no longer valid                                                  |
| 404    | `NOT_FOUND`                                                             | No such row **that this caller can see**. A host or agent in another organization is a 404, never a 403                            |
| 409    | `CONFLICT`, `DUPLICATE_KEY`, `FOREIGN_KEY_VIOLATION`, `RESOURCE_IN_USE` | The write conflicts with what is already there, or the row is still referenced                                                     |
| 500    | `INTERNAL_ERROR`                                                        | Our fault. `request_id` is what to send us                                                                                         |
| 5xx    | `ENGINE_ERROR`                                                          | Command Center is fine; the engine behind it is not. Only reachable from endpoints that call the engine, such as `test-connection` |

`code` is drawn from a closed set, so it is safe to branch on. The table above is the part of that set a normal caller meets; a few others (`TIMEOUT`, `PAYLOAD_TOO_LARGE`, `RATE_LIMITED`) exist for the same reasons they do in any HTTP API. Treat an unrecognised `code` by its status class.

## Rate limiting

`v1` sets no rate limit of its own, but Command Center applies a **per-deployment** one in front of every route, and a request over it comes back `429 RATE_LIMITED`. The default allows a few hundred requests a minute per client, which no ordinary integration approaches - it is there to stop a runaway loop, not to meter you. Because an operator can change it, treat the exact number as a property of the deployment you are calling, not of the API: back off on a `429` rather than pacing yourself to a constant.

Two things do have fixed limits, and they are the ones a bulk caller hits first: a list page is capped at **100 rows** (`limit`), and import and export are capped at **10000 rows**.

Bulk import is the one deliberate exception: `POST /{resource}/batch` returns `200` with a verdict per row even when every row failed, because the per-row results *are* the answer. A `4xx` there means the request itself was wrong.

## Versioning

`v1` is a promise about shapes, not a frozen file. Fields and optional parameters get **added**; nothing documented here is renamed, removed, or given a new meaning without a new version. So:

* read fields by name and ignore the ones you do not know,
* do not depend on field order or on the absence of a field,
* do not depend on undocumented fields. A response may carry extras the UI needs; those are not part of the contract,
* treat a documented enum as open unless it is stated closed. Host `tags` and `target_type` are closed sets; a task's `status` is a state machine that gains steps.

There is no deprecation channel today: the versioned path is the whole signal, and a `v2` would be a new path served beside `v1`. Watch the release notes for the release you upgrade to.

## Next

<CardGroup cols={2}>
  <Card title="API Keys" icon="key" href="/0.12/configure/api-keys">
    Generating, scoping, and revoking keys.
  </Card>

  <Card title="Import and Export" icon="file-csv" href="/0.12/configure/import-export">
    Bulk CSV and JSON, in and out.
  </Card>
</CardGroup>
