---
slug: agents/api-lifecycle
title: Agent lifecycle API
kind: reference
surface: agents
summary: REST reference for the Aetherfy agent lifecycle — create, list, read, update and delete agents, stop, start, archive and restore them, read live machine status, and export an agent's aetherfy.yaml.
sources:
  - aetherfy-control-plane:api/routes/agents.py
  - aetherfy-control-plane:api/routes/agent_health.py
  - aetherfy-control-plane:shared/error_codes.py
  - aetherfy-control-plane:shared/plan_validator.py
---

# Aetherfy agent lifecycle API

Base URL `https://agents.aetherfy.com/api/v1`, bearer authentication, and the shared
error envelope are described on the [Agent REST API index](/agents/api). `{agent}`
accepts a UUID or a name throughout.

## Creating an Aetherfy agent

`POST /api/v1/agents` → 201

```bash
curl -s -X POST https://agents.aetherfy.com/api/v1/agents \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"reporter","runtime":"python3.12","memory_mb":512,"agent_type":"job"}'
```

| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | string | — | **Required.** 1–64 characters, unique per account |
| `description` | string \| null | `null` | Human-readable |
| `agent_type` | enum | `service` | `service` or `job` |
| `runtime` | enum | `python3.11` | See the runtime list below |
| `memory_mb` | integer | `256` | Must be within your plan's maximum |
| `idle_timeout_minutes` | integer | `5` | Within your plan's maximum |
| `keep_alive` | boolean | `false` | Always-on. Counts as usage |
| `entrypoint` | string \| null | `null` | e.g. `main.py`, `index.js` |
| `database_collection` | string \| null | `null` | Collection this agent declares |
| `model_name` | string \| null | `null` | Free-form |
| `spawn_enabled` | boolean | `false` | Allow this agent to spawn `type: job` agents |
| `allowed_workers` | string[] | `[]` | Names this agent may spawn. Empty means no restriction |
| `workspace_name` | string \| null | `null` | Workspace to place the agent in |
| `tier` | enum | `free` | Accepted but authoritative plan comes from your subscription |

Runtimes: `python3.11`, `python3.12`, `python3.13`, `node20`, `node22`, `node20-ts`,
`node22-ts`, `bun`, `dockerfile`.

Creating an agent does **not** deploy it — it reserves the name and the
configuration. See [Deployments](/agents/api-deployments).

**Your agent quota is not checked here.** The quota counts *deployed* agents, and
a freshly created agent is a draft, so you can keep as many drafts as you like on
any plan — including building a replacement for an agent that is currently serving
traffic, before you take the old one down. It is checked when you first deploy an
agent, and when you restore one from the archive.

Which agents hold a slot follows from that. A deployed agent holds one, and
stopping it does **not** give it back: a stopped agent is `paused`, which keeps it
deployed. Archiving or deleting an agent releases its slot. Drafts never held one.

The per-agent caps *are* checked here — memory, idle timeout, always-on and the
`dockerfile` runtime are all validated against your plan before the record is
written, and an over-cap create is refused with `PLAN_LIMIT_EXCEEDED`.

Checking the quota at deploy rather than at create is not merely a deferral, and
the deploy-time check is not redundant with anything: an agent created under your
limit can reach its first deploy after a downgrade, or after another agent took
the last slot.

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_NAME_TAKEN` | 409 | Another agent on this account already has that name |
| `WORKSPACE_NOT_FOUND` | 404 | `workspace_name` does not resolve |
| `PLAN_LIMIT_EXCEEDED` | 403 | Memory, idle timeout, always-on or `dockerfile` runtime exceeds your plan. **Not** agent count — that is checked at deploy |
| `AGENT_COLLECTION_REGION_MISMATCH` | 400 | `database_collection` lives in regions this agent could not reach |
| `SERVICE_UNAVAILABLE` | 503 | Your plan could not be read, so no limit could be checked. **Transient — retry** |

## Listing and reading Aetherfy agents

`GET /api/v1/agents` → 200, an array of agent objects.
`GET /api/v1/agents/{agent}` → 200, one agent object.

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

The agent object carries the creation fields above plus:

| Field | Type | Notes |
|---|---|---|
| `id` | uuid | |
| `user_id` | uuid | |
| `status` | enum | See the status table below |
| `parent_agent_id` | uuid \| null | Set on a spawned worker |
| `regions` | string[] | Regions the agent currently occupies |
| `pending_regions` | string[] | Regions a change is still moving into |
| `regions_total` / `regions_ready` | integer | Convergence counters |
| `is_degraded` | boolean | Some regions are not serving |
| `degraded_reason` | string \| null | Why, when `is_degraded` is true |
| `failure_code` | string \| null | Why the agent is `failed`, when nothing else on the record says. Branch on this |
| `failure_message` | string \| null | The same reason as prose, for display. Wording may change between releases |
| `cron_schedule` | string \| null | The 5-field UTC cron expression, when a schedule is set |
| `cron_next_run_at` | timestamp \| null | Next scheduled fire |
| `cron_paused` | boolean | Whether the schedule is paused |
| `cron_last_run_at` | timestamp \| null | |
| `cron_last_status` | string \| null | |
| `cron_last_reason` | string \| null | Why the last occurrence did not fire, when it did not |
| `created_at` / `updated_at` | timestamp | |

`failure_code` is set only where the reason cannot be recovered from anything else.
Today it carries one value, `app_lost_on_provider`: the compute plane no longer has
an app for the agent. Your code, your secrets and the agent's address are all kept,
and `afy deploy <agent>` brings it back. A null `failure_code` is not a claim that
the agent is healthy — an agent whose build did not compile has null here and
explains itself through its deployment's error instead.

`failure_code` is separate from `is_degraded` on purpose. `is_degraded` means the
agent is serving and something about it is wrong; a failed agent is not serving at
all, so the two never describe the same condition.

The `cron_*` fields describe a [scheduled task](/agents/scheduled-tasks). `cron` names
the expression format only.

### Aetherfy agent statuses

| Status | Meaning |
|---|---|
| `pending` | Created, nothing built yet |
| `building` | A deployment is building |
| `deploying` | Built, machines coming up |
| `running` | Serving |
| `failed` | The last deployment failed |
| `paused` | **You** stopped it — see `stop` below |
| `stopped` | Aetherfy stopped it because the account was suspended |
| `usage_paused` | Aetherfy paused it because a spend limit was reached |
| `suspended` | Account-level suspension |
| `archived` | Torn down, configuration and code retained |
| `deleting` / `deleted` | Terminal |

`paused` and `stopped` are different states with different causes, and the
distinction matters when you are diagnosing an agent that is not serving. `paused` is
something you did and can undo with `start`. `stopped` means the account was
suspended — settle it and Aetherfy returns the agent to `running` on its own. See
[Billing and spend caps](/platform/billing).

## Updating an Aetherfy agent

`PATCH /api/v1/agents/{agent}` → 200. Every field is optional; omitted fields are
untouched. Setting `workspace_name` to `null` moves the agent out of any workspace.

```bash
curl -s -X PATCH https://agents.aetherfy.com/api/v1/agents/reporter \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"memory_mb":1024,"keep_alive":true}'
```

Configuration changes here take effect on the **next deploy**. To change what is
running now, deploy again.

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_NOT_FOUND` | 404 | |
| `AGENT_TOGGLE_RATE_LIMITED` | 429 | Always-on was toggled too rapidly |
| `PLAN_LIMIT_EXCEEDED` | 403 | The new configuration exceeds your plan |
| `AGENT_COLLECTION_REGION_MISMATCH` | 400 | The declared collection's regions are unreachable from the agent's |
| `WORKSPACE_NOT_FOUND` | 404 | The target `workspace_name` does not resolve |
| `AGENT_DEPLOYMENT_OUTSIDE_NEW_WORKSPACE_SCOPE` | 400 | The agent's live deployment sits outside the target workspace's region set. Carries a `violations` array naming the deployment version, its regions and the cap |
| `SOFT_CAP_EXCEEDED` / `DUNNING_FROZEN` | 403 | Configuration changes are frozen — see [Billing](/platform/billing) |

## Stopping and starting an Aetherfy agent

`POST /api/v1/agents/{agent}/stop` → 202 `{"status": "paused", "machines": "stopping", "agent_id": "…"}`
`POST /api/v1/agents/{agent}/start` → 202 `{"status": "running", "agent_id": "…"}`

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

Stop moves the agent to `paused` — note the endpoint is named for the verb and the
resulting status is `paused`, not `stopped`. The agent keeps its slot against your
plan's agent count, its configuration and its code. Start brings the same machines
back without rebuilding.

The pause is in force the moment the call returns: the status is `paused`, billing
for machine uptime stops at that instant, and a deploy or rollback against the agent
is already refused. The `machines` field reports the remaining work — the machines
wind down in the background, and the machine states you see under
`GET /api/v1/agents/{agent}/status` catch up afterwards rather than immediately.

Nothing you can do with the agent waits on that. `start` works straight away, even
on machines still shown as `stopping`, and the pause is already reflected everywhere
it matters. Treat the machine states as a view that trails, not as the pause itself.

Because a paused agent still holds its quota slot, **you cannot pause your way under
an agent limit**. Archive instead.

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_ALREADY_PAUSED` | 409 | Already paused |
| `AGENT_NOT_PAUSED` | 409 | `start` on an agent that was not paused |
| `AGENT_ALREADY_ARCHIVED` | 409 | Restore it first |
| `AGENT_HAS_PENDING_DEPLOYMENTS` | 409 | Wait for the build to finish or fail |
| `AGENT_NOT_PAUSEABLE_SYSTEM_STATE` | 409 | Aetherfy owns the current state, e.g. `usage_paused` |
| `AGENT_OPERATION_IN_PROGRESS` | 409 | A worker holds the agent. **Transient — retry** |
| `AGENT_NO_MACHINES` | 400 | Nothing to start; deploy first |
| `AGENT_PAUSE_FAILED` / `AGENT_RESUME_FAILED` | 503 | The machine host was unreachable. The status is unchanged — retry |

`AGENT_OPERATION_IN_PROGRESS` is the only 409 here that is safe to retry blindly; the
others describe a state you have to change first.

## Archiving and restoring an Aetherfy agent

`POST /api/v1/agents/{agent}/archive` → 202 `{"status": "archiving", …}`
`POST /api/v1/agents/{agent}/restore` → 202 `{"status": "restoring", …}`

Archiving tears the agent's infrastructure down and **releases its quota slot**,
keeping its configuration and stored code. It is the way to get under your agent
limit without deleting anything.

Restoring rebuilds it — and re-checks quota at that moment, because other agents may
have taken the slot meanwhile. An archived agent is therefore not guaranteed to come
back if you have filled your plan since.

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_ALREADY_ARCHIVED` | 409 | |
| `AGENT_NOT_ARCHIVED` | 409 | `restore` on an agent that was not archived |
| `AGENT_NOT_ARCHIVABLE_SYSTEM_STATE` | 409 | Aetherfy owns the current state |
| `AGENT_HAS_PENDING_DEPLOYMENTS` | 409 | |
| `AGENT_HAS_WORKER_DEPENDENTS` | 409 | Another agent lists this one in `spawn.workers`. Carries `dependents` |
| `PLAN_LIMIT_EXCEEDED` | 403 | On `restore`: no quota slot free any more |

## Deleting an Aetherfy agent

`DELETE /api/v1/agents/{agent}` → 202 `{"status": "deleting", "agent_id": "…"}`

Deletion is permanent and asynchronous: the agent moves to `deleting` and a worker
tears down machines and stored code. It is refused while something still depends on
the agent.

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_HAS_PENDING_DEPLOYMENTS` | 409 | Carries `pending_deployments`. Wait for `active` or `failed` |
| `AGENT_HAS_WORKER_DEPENDENTS` | 409 | Carries `dependents` — the agents naming this one in `spawn.workers` |

Deleting is not how you free a quota slot temporarily. Archive does that reversibly.

## Reading live Aetherfy machine status

`GET /api/v1/agents/{agent}/status` → 200

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

```json
{
  "agent_id": "6f1c2b7e-0a2d-4f8e-9c31-2b0d5a7e4411",
  "name": "reporter",
  "status": "running",
  "url": "https://reporter-k3m7x2.aetherfy.dev",
  "machines": [
    {
      "id": "1857a3d4f21e08",
      "region": "iad",
      "state": "started",
      "source": "live",
      "health": "passing",
      "last_check": "2026-08-19T09:12:44Z",
      "instance_id": "01J9…"
    }
  ]
}
```

This route differs from `GET /agents/{agent}` in one important way: it queries the
machine host directly, so `machines[].state` is real-time rather than whatever
Aetherfy last recorded.

**Read `source` before trusting `state`.** It is `"live"` when that machine was in
the host's response, and `"cached"` when Aetherfy fell back to its own last-known
value because the host read failed. A cached reading can be up to about a minute
stale. If your automation waits for a machine to reach a state, require
`source == "live"` — otherwise a stale reading can satisfy the wait before the state
is actually true.

`url` is `null` until the agent has machines. Destroyed machines are omitted.

## Exporting an Aetherfy agent's aetherfy.yaml

`GET /api/v1/agents/{agent}/yaml` → 200, `Content-Type: application/yaml`

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

The response body is YAML, not JSON. It is the declarative subset of the agent's
current configuration — the same content the dashboard's download button produces —
so it round-trips: write it into a project directory and deploy, and you get the
agent back. See [the aetherfy.yaml reference](/agents/aetherfy-yaml).

Returns 404 `AGENT_NOT_FOUND` for an agent being deleted.
