---
slug: agents/api
title: Agent REST API
kind: reference
surface: agents
summary: Index of the Aetherfy agent control-plane REST API at https://agents.aetherfy.com/api/v1 — base URL, bearer authentication, the error envelope and how it differs from the vector API, rate limiting, and the full route table across all fifty endpoints.
sources:
  - aetherfy-control-plane:main.py
  - aetherfy-control-plane:api/middleware/auth.py
  - aetherfy-control-plane:api/middleware/rate_limit.py
  - aetherfy-control-plane:shared/error_codes.py
  - aetherfy-control-plane:shared/api_errors.py
  - aetherfy-control-plane:shared/plan_validator.py
---

# Aetherfy agent REST API

## Base URL and authentication for the Aetherfy control plane

The Aetherfy control plane is served from `https://agents.aetherfy.com`. Every
customer-facing route lives under `/api/v1`. Authentication is a bearer token:

```
Authorization: Bearer <api key>
```

```bash
curl -s https://agents.aetherfy.com/api/v1/agents \
  -H "Authorization: Bearer $AETHERFY_API_KEY"
```

This is the same API key that authenticates the Aetherfy vector API and the same
one `afy` uses — see [API keys](/platform/api-keys). The key's prefix selects the
environment: `afy_live_` keys address your production account and `afy_test_` keys
address the test environment. You never send an environment header; the credential
encodes it.

A request with no `Authorization` header is refused with 401 `MISSING_API_KEY`. A
key that does not resolve is refused with 401 `INVALID_API_KEY` — the same two
codes the Aetherfy vector API answers with.

## The Aetherfy control plane is a different API from the vector API

Aetherfy runs two independent HTTP surfaces, and a client written against one will
be subtly wrong against the other. The differences are not cosmetic.

| | Vector API (`vectors.aetherfy.com`) | Control plane (`agents.aetherfy.com`) |
|---|---|---|
| What it does | Collections, points, search | Agents, deployments, runs, secrets, workspaces |
| Error envelope | `{"error": {"code", "message"}}` | `{"detail": {"code", "message"}}` |
| No credentials | 401 `MISSING_API_KEY` | 401 `MISSING_API_KEY` |
| Bad credentials | 401 `INVALID_API_KEY` | 401 `INVALID_API_KEY` |
| Suspended account | 403 `ACCOUNT_SUSPENDED` | 403 `ACCOUNT_SUSPENDED` |
| Rate-limit window | Fixed 60-second bucket | Sliding 60-second window |
| `Retry-After` on 429 | Not sent | Sent |
| Request budget | Vector API requests/min | Control-plane requests/min — a separate budget |

**The envelope is the trap, not the code.** The auth codes are identical across
both surfaces, so the string you match on is the same either way — but the field
it arrives in is not. A handler that reads `error.code` finds nothing on a
control-plane response, and one that reads `detail.code` finds nothing on a vector
response. Read the field that matches the host you called.

Historical note, for a client written before 2026-08-20: the control plane used to
answer these three conditions with `AUTH_REQUIRED`, `AUTH_INVALID_API_KEY` and
`AUTH_ACCOUNT_SUSPENDED`. Those strings are retired and nothing returns them now.

## The Aetherfy control-plane error envelope

Every error carries a stable `code` inside a `detail` object:

```json
{
  "detail": {
    "code": "AGENT_NOT_FOUND",
    "message": "Agent 'reporter' not found",
    "agent": "reporter"
  }
}
```

Codes are append-only, so switching on `detail.code` is safe. The auth rename noted
above is the single exception on record, and it is closed. Individual errors add
context fields beside `message` —
`agent`, `field`, `allowed`, `pending_deployments`, `dependents`, `current_state`
and others — documented with the endpoint that emits them.

**A DIFFERENT ENVELOPE COMES FROM YOUR AGENT’S OWN ADDRESS.** Everything above
describes `agents.aetherfy.com`, the control plane. Requests you send to an
agent’s own URL do not go through it -- they are answered by the edge in front
of `*.aetherfy.dev`, which uses `{"error": {"code", "message"}}` rather than
`detail.code`, and returns an `aetherfy-request-id` header on every response.
Three codes are its own:

| Code | Status | Means |
|---|---|---|
| `AGENT_NOT_FOUND` | 404 | The hostname is not an agent address. Nothing was contacted. |
| `AGENT_NOT_REACHABLE` | 404 | A well-formed agent address where nothing identified itself as a running agent. |
| `AGENT_UPSTREAM_FAILURE` | 502 or 504 | A gateway answered before the agent did, so no agent response was produced. |

`AGENT_NOT_FOUND` is spelled the same here and on the control plane and means
the same thing; only the envelope differs. `AGENT_NOT_REACHABLE` has no control
plane equivalent -- the control plane knows what an agent SHOULD be doing, and
the edge only knows what answered. When they disagree, the control plane is
authoritative: run `afy agents list` rather than inferring state from a request
that failed.

`AGENT_UPSTREAM_FAILURE` keeps the gateway's own status -- 502 or 504 -- rather
than reporting a 404 like the two above. That difference is the useful part:
the status is the retry signal, and this condition is usually transient, while
a 404 would tell you never to retry and would also claim the address resolves
to nothing. Your agent may be perfectly healthy; something in front of it
answered first.

It names no cause, deliberately. A request body large enough to be refused
produces this, and so does an agent that stopped responding -- the edge sees
the same thing in both cases and will not guess between them. Retry; if it
persists, `afy agents logs` shows what your agent did with the request.

**The envelope covers routed errors only.** A request to a path that matches no
route never reaches Aetherfy's error handling, so it gets the framework default —
`{"detail": "Not Found"}` with a plain string, not an object — and
`detail.code` is `undefined`. If you are reading a `code` off a 404, check that
you called a path that exists before concluding the code is missing. The same gap
opens at the other end of the request: the control plane installs error handlers
for request validation and for database lock timeouts only, so a server error
neither covers falls through to the framework's fallback, which answers `500`
with the plain text `Internal Server Error` — not JSON at all, so parsing the
body throws before you can look for a `code`. A 500 that does carry one, such as
`AUTHENTICATION_ERROR` below, was raised by a route and kept the envelope.

**Request-validation errors keep the envelope.** When a request body fails schema
validation before reaching the handler, Aetherfy wraps the framework's per-field
errors rather than passing them through, so `detail` is still an object with a
`code` — `VALIDATION_ERROR` — and the field-level detail moves into a
`violations` array beside `message`:

```json
{
  "detail": {
    "code": "VALIDATION_ERROR",
    "message": "Extra inputs are not permitted",
    "violations": [
      {"type": "extra_forbidden", "loc": ["body", "memorymb"],
       "msg": "Extra inputs are not permitted", "input": 512}
    ]
  }
}
```

So `detail.code` is safe to read on a 422 like any other error, and field-level
handling reads `detail.violations`. Each violation carries the framework's own
`loc` / `msg` / `type`, verbatim.

## Errors every Aetherfy control-plane endpoint can return

These come from middleware and apply to every authenticated route below, so they are
stated once here rather than repeated on each endpoint.

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_API_KEY` | 401 | No `Authorization` header. Response carries `WWW-Authenticate: Bearer` |
| `INVALID_API_KEY` | 401 | The key does not resolve — it may have been revoked |
| `ACCOUNT_SUSPENDED` | 403 | The account is suspended for unpaid bills. Settle it in billing settings |
| `AUTH_SUBSCRIPTION_INACTIVE` | 403 | The subscription is not active, trialing or past due. Carries `subscription_status`. Control-plane only — the vector API does not gate on subscription status, which is why this one keeps the `AUTH_` prefix |
| `AUTHENTICATION_ERROR` | 500 | Authentication itself failed. Retry |
| `RATE_LIMIT_EXCEEDED` | 429 | The control-plane per-minute budget is exhausted — see below |
| `VALIDATION_ERROR` | 422 | The request body did not match the schema. Field-level errors are in `detail.violations` |
| `RESOURCE_BUSY` | 503 | A background worker held a row lock too long. Transient — retry in a few seconds |
| `SERVICE_UNAVAILABLE` | 503 | A service this request depended on was unreachable, so Aetherfy could not answer either way. Transient — retry in a few seconds |

`RESOURCE_BUSY` is worth handling explicitly. Aetherfy bounds how long any request
may wait on a row a worker is holding, and answers 503 rather than hanging. It is
always safe to retry.

`SERVICE_UNAVAILABLE` is the other transient 503, and the two are worth telling
apart when you read logs: `RESOURCE_BUSY` is Aetherfy's own database contending on
a row, while `SERVICE_UNAVAILABLE` means a dependency was down. Today it reaches
you from any route that reads your plan before acting — creating an agent,
deploying, spawning, placing a collection or workspace — because a plan that
cannot be read is a limit that cannot be checked, and Aetherfy refuses rather than
guessing. Nothing is written when it fires. The vector API at `vectors.aetherfy.com`
answers its own dependency outages with this same code and status, in `error.code`
rather than `detail.code`.

## Rate limiting on the Aetherfy control plane

The control plane has its own per-minute request budget, separate from the vector
API's — saturating one leaves the other untouched. Per-plan numbers are in
[Limits](/platform/limits).

Aetherfy returns `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
`X-RateLimit-Reset` on successful responses, so you can pace without provoking a
429. When you do exceed it, the 429 tells you how long to wait:

```json
{
  "detail": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Limit: 500 requests/minute. Try again in 23 seconds.",
    "limit": 500,
    "retry_after_seconds": 23
  }
}
```

with `Retry-After: 23` in the headers. Enterprise accounts are unlimited and receive
**no** `X-RateLimit-*` headers at all, so treat the headers as optional.

`GET /api/v1/health` is exempt from rate limiting.

## Addressing an Aetherfy agent by name or by id

Every route spelled `/agents/{agent}` below accepts **either** the agent's UUID or
its name. These are equivalent:

```bash
curl -s https://agents.aetherfy.com/api/v1/agents/reporter \
  -H "Authorization: Bearer $AETHERFY_API_KEY"

curl -s https://agents.aetherfy.com/api/v1/agents/6f1c2b7e-0a2d-4f8e-9c31-2b0d5a7e4411 \
  -H "Authorization: Bearer $AETHERFY_API_KEY"
```

Names are unique per account, so there is no ambiguity. A name that does not resolve
returns 404 `AGENT_NOT_FOUND` — the same answer as an unknown UUID, and the same
answer as an agent belonging to another account. Aetherfy does not distinguish "does
not exist" from "not yours".

## Asynchronous responses in the Aetherfy control plane

Anything that touches machines is accepted and completed in the background.
**HTTP 202 means the request was accepted, not that the work finished.**

| Response | What it means |
|---|---|
| 200 | Done. The body is the result |
| 201 | Created. The body is the new resource |
| 202 | Accepted. Work continues in the background — poll for completion |
| 204 | Done, no body |

The 202 bodies carry a progress label, not a status. `POST /agents/{agent}/stop`
answers `{"status": "paused", …}` and the agent's own `status` field becomes
`paused`; `archive` answers `"archiving"` and `restore` answers `"restoring"`, which
are *not* members of the status enum. Poll
[`GET /agents/{agent}`](/agents/api-lifecycle) for real state, or
[`GET /operations/{operation_id}`](/agents/api-workspaces) for region changes.

## Route index for the Aetherfy control plane

Fifty endpoints, grouped by the page that documents them.

### [Agent lifecycle](/agents/api-lifecycle)

| Method | Path |
|---|---|
| POST | `/api/v1/agents` |
| GET | `/api/v1/agents` |
| GET | `/api/v1/agents/{agent}` |
| PATCH | `/api/v1/agents/{agent}` |
| DELETE | `/api/v1/agents/{agent}` |
| POST | `/api/v1/agents/{agent}/stop` |
| POST | `/api/v1/agents/{agent}/start` |
| POST | `/api/v1/agents/{agent}/archive` |
| POST | `/api/v1/agents/{agent}/restore` |
| GET | `/api/v1/agents/{agent}/status` |
| GET | `/api/v1/agents/{agent}/yaml` |

### [Deployments](/agents/api-deployments)

| Method | Path |
|---|---|
| POST | `/api/v1/agents/{agent}/deploy` |
| GET | `/api/v1/deployments` |
| GET | `/api/v1/agents/{agent}/deployments` |
| GET | `/api/v1/deployments/{deployment_id}` |
| POST | `/api/v1/agents/{agent}/deployments/{version}/rollback` |
| POST | `/api/v1/agents/{agent}/deployments/{version}/cancel` |
| GET | `/api/v1/deployments/{deployment_id}/payload` |
| POST | `/api/v1/agents/{agent}/github` |
| GET | `/api/v1/agents/{agent}/github` |
| DELETE | `/api/v1/agents/{agent}/github` |

### [Runs, schedules and spawns](/agents/api-runs)

| Method | Path |
|---|---|
| POST | `/api/v1/agents/{agent}/run` |
| GET | `/api/v1/agents/{agent}/runs` |
| POST | `/api/v1/agents/{agent}/schedule/pause` |
| POST | `/api/v1/agents/{agent}/schedule/resume` |
| POST | `/api/v1/agents/{agent}/spawn` |
| GET | `/api/v1/agents/{agent}/logs` |

### [Secrets](/agents/api-secrets)

| Method | Path |
|---|---|
| POST | `/api/v1/agents/{agent}/secrets` |
| GET | `/api/v1/agents/{agent}/secrets` |
| DELETE | `/api/v1/agents/{agent}/secrets/{key}` |
| POST | `/api/v1/agents/{agent}/secrets/{key}/rotate` |
| POST | `/api/v1/workspaces/{workspace}/secrets` |
| GET | `/api/v1/workspaces/{workspace}/secrets` |
| DELETE | `/api/v1/workspaces/{workspace}/secrets/{key}` |

### [Workspaces, regions and operations](/agents/api-workspaces)

| Method | Path |
|---|---|
| POST | `/api/v1/workspaces` |
| GET | `/api/v1/workspaces` |
| GET | `/api/v1/workspaces/{workspace}` |
| PATCH | `/api/v1/workspaces/{workspace}` |
| DELETE | `/api/v1/workspaces/{workspace}` |
| GET | `/api/v1/workspaces/{workspace}/agents` |
| PATCH | `/api/v1/workspaces/{workspace}/regions` |
| PATCH | `/api/v1/collections/{collection_id}` |
| GET | `/api/v1/operations/{operation_id}` |

### [Account and usage](/agents/api-account)

| Method | Path |
|---|---|
| GET | `/api/v1/auth/me` |
| PATCH | `/api/v1/users/me/home-region` |
| GET | `/api/v1/usage/summary` |
| GET | `/api/v1/usage/agent/{agent_id}/events` |
| GET | `/api/v1/auth/github` |
| GET | `/api/v1/auth/github/status` |
| DELETE | `/api/v1/auth/github` |

## Aetherfy control-plane routes that are not customer API

Two groups of routes exist on `agents.aetherfy.com` and are deliberately absent from
the index above.

Everything under `/internal/` is a private contract between the Aetherfy dashboard
and the control plane, authenticated by a shared secret rather than by your API key.
Your key will not open it. `POST /api/v1/webhooks/github` and
`POST /api/v1/webhooks/github/{agent_id}` are called by GitHub and authenticated by
webhook signature, not by a bearer token.

The health probes — `GET /api/v1/health`, `/readiness` and `/liveness` — are
unauthenticated and exist for Aetherfy's own monitoring. They are not part of the
customer API and carry no compatibility promise.

**There is no build-log endpoint.** Read build progress from
[`GET /api/v1/deployments/{deployment_id}`](/agents/api-deployments), whose `state`
and `error_message` are real. An agent's *runtime* logs are a different thing and do
have a route — [`GET /agents/{agent}/logs`](/agents/api-runs).

## Machine-readable index of the Aetherfy control plane

The routes on this page are also published as OpenAPI 3.1 at
[https://docs.aetherfy.com/agents-openapi.json](/agents-openapi.json). It is a
**second** document, separate from [openapi.json](/openapi.json), which describes the
vector API — different origin, different auth budget, different error envelope.
See [Aetherfy for AI agents](/platform/ai).

## The Aetherfy CLI does most of this for you

Every route here is reachable through `afy`, which handles archiving, auth and
polling. The REST API is the right tool when you are building automation that cannot
shell out, or a client in a language with no Aetherfy SDK. Start at
[the CLI reference](/cli) if you are not.
