---
slug: agents/api-deployments
title: Deployments API
kind: reference
surface: agents
summary: REST reference for Aetherfy deployments — uploading a code archive to deploy, listing and reading deployment records, rolling back to a previous version, cancelling an in-flight build, reading a spawn payload, and linking an agent to a GitHub repository.
sources:
  - aetherfy-control-plane:api/routes/deployments.py
  - aetherfy-control-plane:api/routes/github.py
  - aetherfy-control-plane:shared/plan_validator.py
  - aetherfy-control-plane:shared/error_codes.py
---

# Aetherfy deployments 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 an agent UUID or its name throughout.

## Deploying an Aetherfy agent

`POST /api/v1/agents/{agent}/deploy` → 202

This is a **multipart upload**, not JSON. One part, `code_archive`, carrying a
gzipped tarball of your agent directory.

```bash
tar czf agent.tar.gz -C ./my-agent .

curl -s -X POST "https://agents.aetherfy.com/api/v1/agents/reporter/deploy" \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -F "code_archive=@agent.tar.gz"
```

| Parameter | In | Notes |
|---|---|---|
| `code_archive` | multipart form | **Required.** Gzipped tarball of the agent directory |
| `confirm_overage` | query | Default `false`. Set `true` to accept a deploy that adds usage |

If `aetherfy.yaml` sits at the archive root, Aetherfy parses it and applies it to the
agent before building — runtime, memory, regions, schedule, spawn configuration. Your
uploaded configuration wins over what the agent record already held, so the archive
is the source of truth for a deployed agent. See
[the aetherfy.yaml reference](/agents/aetherfy-yaml).

The 202 response is a deployment object (below). The build has been queued, not
finished — poll the deployment to follow it.

### Cost confirmation on an Aetherfy deploy

A deploy that would push the account into paid usage is answered **402
`OVERAGE_CONFIRM_REQUIRED`** the first time, naming the additional monthly amount in
dollars. This is a disclosure, not a block: repeat the request with
`?confirm_overage=true` and it proceeds.

```bash
curl -s -X POST "https://agents.aetherfy.com/api/v1/agents/reporter/deploy?confirm_overage=true" \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -F "code_archive=@agent.tar.gz"
```

### Errors on an Aetherfy deploy

Aetherfy checks account-level gates before per-agent ones, so a frozen account is
told *that* rather than being sent to delete an agent that would not unblock it.

| Code | HTTP | Meaning |
|---|---|---|
| `SOFT_CAP_EXCEEDED` | 403 | A spend limit was reached. Existing agents keep running; new deploys are refused |
| `DUNNING_FROZEN` | 403 | A payment failed and the account is in its grace period |
| `OVERAGE_CONFIRM_REQUIRED` | 402 | Confirm the added cost — see above |
| `PLAN_LIMIT_EXCEEDED` | 403 | Agent count, memory, regions or always-on exceeds the plan |
| `DEPLOY_REGIONS_NOT_IN_SCOPE` | 403 | Requested regions are outside the workspace's region set |
| `DEPLOY_REGIONS_NOT_IN_COLLECTION_SCOPE` | 403 | The declared collection is not replicated to every deploy region |
| `INVALID_REGION` | 400 | A `regions:` entry is not a region Aetherfy runs in. The body's `regions` array names the bad ones |
| `AGENT_PAUSED_CANNOT_DEPLOY` | 409 | Start the agent first |
| `DEPLOYMENT_IN_PROGRESS` | 409 | A build is already running. **Transient — retry** |
| `DEPLOYMENT_ARCHIVE_TOO_LARGE` | 413 | The upload exceeded the archive cap |
| `DEPLOYMENT_CONFIG_PARSE_ERROR` | 422 | `aetherfy.yaml` is malformed or invalid |
| `RUNTIME_IMMUTABLE` | 422 | An agent's runtime cannot change after its first deploy |
| `AGENT_SCHEDULE_NOT_ALLOWED_ON_WORKER` | 422 | A spawned worker cannot carry a `schedule:` |
| `COLLECTION_NOT_FOUND` | 404 | `database_collection` does not resolve |
| `WORKER_NOT_FOUND` | 404 | A name in `spawn.workers` does not resolve |
| `DEPLOYMENT_UPLOAD_FAILED` | 500 | Aetherfy could not store the archive. Retry |

The agent-count limit is enforced **here**, not at
[agent creation](/agents/api-lifecycle) — creating an agent is free, because the
limit counts deployed agents and a new agent is a draft. So
`PLAN_LIMIT_EXCEEDED` on the first deploy of an agent that created cleanly is
expected behaviour and not a contradiction: the create never claimed a slot for
it. Either every slot was already taken when you created the draft, or one was
free then and is not now, because a downgrade landed or another agent took the
last one in between.

## Reading Aetherfy deployments

`GET /api/v1/deployments` → 200, every deployment on the account, newest first.
Accepts `?agent_id=<uuid>` to narrow.
`GET /api/v1/agents/{agent}/deployments` → 200, one agent's deployments.
`GET /api/v1/deployments/{deployment_id}` → 200, one deployment.

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

```json
{
  "id": "0b7d1f66-3a9e-4c25-9f10-77c2b4e51a08",
  "agent_id": "6f1c2b7e-0a2d-4f8e-9c31-2b0d5a7e4411",
  "version": 7,
  "state": "active",
  "image_size_mb": 143,
  "regions": ["iad", "fra"],
  "pending_regions": [],
  "regions_total": 2,
  "regions_ready": 2,
  "queue_position": null,
  "is_serving": true,
  "is_degraded": false,
  "is_ephemeral": false,
  "can_redeploy": true,
  "can_rollback": true,
  "cancellation_requested": false,
  "cancellation_reason": null,
  "pending_region_alert_stage": null,
  "created_at": "2026-08-19T09:04:11Z",
  "started_at": "2026-08-19T09:04:19Z",
  "deployed_at": "2026-08-19T09:06:02Z",
  "error_message": null
}
```

### Aetherfy deployment states

| State | Meaning |
|---|---|
| `queued` | Waiting for a builder. `queue_position` is set |
| `building` | Building the image |
| `deploying` | Built; machines coming up region by region |
| `active` | Serving |
| `failed` | Terminal. Read `error_message` |
| `rolled_back` | Superseded by a rollback to an earlier version |
| `superseded` | A newer deployment replaced it |
| `completed` | Terminal, and **not** a failure — an ephemeral run that finished |

`completed` only appears on ephemeral deployments: the one-shot runs produced by
[a scheduled task, a manual run, or a spawn](/agents/api-runs). A long-lived service
deployment never reaches it.

Watch `is_serving` rather than `state` alone when you need "is traffic being
answered". A multi-region deployment can be `deploying` with some regions already
live, which is what `regions_ready` against `regions_total` tells you, and
`is_degraded` with `pending_region_alert_stage` says a region is taking too long.

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_NOT_FOUND` | 404 | Unknown id, or it belongs to another account |
| `DEPLOYMENT_ACCESS_DENIED` | 403 | The deployment's agent is not yours |

## Rolling back an Aetherfy agent

`POST /api/v1/agents/{agent}/deployments/{version}/rollback` → 202

`{version}` is the integer version you want to return to, not a deployment id.

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

Rollback re-deploys a previously built image, so it does not rebuild and does not
need the original source. It creates a **new** version rather than reviving the old
number. Only versions whose image still exists are valid targets; the rejection names
that as the reason.

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_ROLLBACK_TARGET_INVALID` | 422 | That version is not a rollback target — most often its image is no longer available. `can_rollback` on the deployment object answers this before you call |
| `DEPLOYMENT_NOT_FOUND` | 404 | No such version for this agent |
| `DEPLOYMENT_IN_PROGRESS` | 409 | A build is running. **Transient — retry** |
| `AGENT_PAUSED_CANNOT_ROLLBACK` | 409 | Start the agent first |
| `SOFT_CAP_EXCEEDED` / `DUNNING_FROZEN` | 403 | The account is frozen |

See [Rollback](/agents/rollback) for the operational guide.

## Redeploying an Aetherfy agent

`POST /api/v1/agents/{agent}/deployments/{version}/redeploy` → 202

`{version}` is the integer version whose source you want rebuilt, not a deployment id.

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

Redeploy runs a **fresh build** from that version's stored code archive, which is what
separates it from rollback: rollback re-deploys an image that already exists, so it
cannot pick up a secret written since. Secrets are injected while the machine is built,
so redeploy is how a stored secret reaches a running service. It creates a new version.

The agent keeps its current configuration — the archive is not re-read for memory,
regions or any other setting, so redeploying an old version never reverts a change you
made after it shipped.

Archives are not kept forever: Aetherfy stores them for the three most recent successful
deployments, and deletes one when its build fails. `can_redeploy` on the deployment
object tells you whether a given version still has its source; read it rather than
inferring from the state.

This applies to GitHub-deployed versions too: a version whose archive has aged out is
not redeployable even though the commit is still in your repository. Aetherfy does not
re-clone the commit, because re-reading its `aetherfy.yaml` would revert configuration
you changed afterwards. Push again instead — a push deploys with current secrets.

**Redeploy is not a retry for a failed build.** A build that fails has its archive
deleted immediately, so there is nothing left to rebuild — and re-running an identical
build would fail identically. To recover from a failed build, deploy your code again
with `afy deploy`, or push to the linked repository. What redeploy *is* for is taking a
deployment that built successfully and running its build again against current secrets.
The one failure it does cover is a deployment that built but failed to launch, which
keeps its archive.

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_REDEPLOY_SOURCE_UNAVAILABLE` | 422 | That version's stored code archive is gone, so it cannot be rebuilt |
| `DEPLOYMENT_NOT_FOUND` | 404 | No such version for this agent |
| `DEPLOYMENT_IN_PROGRESS` | 409 | A build is running. **Transient — retry** |
| `AGENT_PAUSED_CANNOT_REDEPLOY` | 409 | Start the agent first |
| `SOFT_CAP_EXCEEDED` / `DUNNING_FROZEN` | 403 | The account is frozen |

## Cancelling an in-flight Aetherfy deployment

`POST /api/v1/agents/{agent}/deployments/{version}/cancel` → 202, returning the
deployment object with `cancellation_requested: true`.

Cancellation is cooperative: Aetherfy flags the deployment and the builder stops at
its next checkpoint. A deployment that already reached a terminal state cannot be
cancelled.

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_TERMINAL_CANNOT_CANCEL` | 409 | Already `active`, `failed`, `completed`, `superseded` or `rolled_back` |
| `DEPLOYMENT_NOT_FOUND` | 404 | |

## Reading the payload a run was started with

`GET /api/v1/deployments/{deployment_id}/payload` → 200 `{"payload": { … }}`

There are two ways to reach this route, and they differ only in where the
`{deployment_id}` comes from.

**From inside the agent.** A spawned or scheduled `type: job` agent calls it on
itself. Aetherfy injects `AETHERFY_SPAWN_ID` and `AETHERFY_API_KEY` into the
machine's environment, and `AETHERFY_SPAWN_ID` is the deployment id to use:

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

**From outside, to inspect a run that already happened.** The `id` of a row in
[run history](/agents/api-runs) *is* the deployment id — the same value — so list the
runs and pass one straight through:

```bash
# The id of the most recent run.
RUN_ID=$(curl -s "https://agents.aetherfy.com/api/v1/agents/reporter/runs?limit=1" \
  -H "Authorization: Bearer $AETHERFY_API_KEY" | jq -r '.[0].id')

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

A spawn's id comes back as `spawn_id` on the
[spawn response](/agents/api-runs) instead, since a spawned run does not appear in the
child's run history.

`payload` is whatever the spawner sent, or `{}` if it sent nothing.

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_NOT_EPHEMERAL` | 404 | A normal deployment has no payload — only spawned and scheduled runs do |
| `DEPLOYMENT_NOT_FOUND` | 404 | |

## Build logs are not available from the Aetherfy API

There is no endpoint for build output. Aetherfy does not currently expose one, and no
path under `/deployments/` serves build logs.

To follow a build, poll `GET /api/v1/deployments/{deployment_id}` and read `state`,
`queue_position` and `error_message`; a failed build puts its reason in
`error_message`. For an agent's **runtime** output, which is a different thing
entirely and does have a route, see [`GET /agents/{agent}/logs`](/agents/api-runs).

## Linking an Aetherfy agent to a GitHub repository

Push-to-deploy is configured per agent, on top of an account-level GitHub connection
made once at [`/api/v1/auth/github`](/agents/api-account).

`POST /api/v1/agents/{agent}/github` → 201

```bash
curl -s -X POST https://agents.aetherfy.com/api/v1/agents/reporter/github \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"repo":"acme/agents","branch":"main","root_dir":"reporter"}'
```

| Field | Type | Default | Notes |
|---|---|---|---|
| `repo` | string | — | **Required.** `owner/repo` |
| `branch` | string | `main` | Pushes to this branch deploy |
| `root_dir` | string \| null | `null` | Subdirectory holding this agent's code and `aetherfy.yaml`. Omit for a repository root |

The 201 response carries `repo`, `branch`, `root_dir`, `webhook_id` and
`webhook_secret`:

```json
{
  "repo": "acme/agents",
  "branch": "main",
  "root_dir": "reporter",
  "webhook_id": "0000000",
  "webhook_secret": "<shown once — store it now>"
}
```

**`webhook_secret` is returned in this response and nowhere else.** Aetherfy has no
route that reads it back. Capture it when you link, and if you lose it, unlink and
link again to mint a new one.

`GET /api/v1/agents/{agent}/github` → 200 returns `{linked, repo, branch, root_dir,
webhook_id}` — no secret.
`DELETE /api/v1/agents/{agent}/github` → 204 unlinks and removes the webhook.

| Code | HTTP | Meaning |
|---|---|---|
| `GITHUB_NOT_CONNECTED` | 422 | Connect the account first — see [Account and usage](/agents/api-account) |
| `GITHUB_REPO_NOT_FOUND` | 404 | The repository is not visible to the installation |
| `GITHUB_API_ERROR` | 503 | GitHub was unreachable. Retry |
| `GITHUB_WEBHOOK_CREATE_FAILED` | 422 | GitHub rejected the webhook — most often one already exists on the repository |
| `GITHUB_PERSISTENCE_FAILED` | 500 | The link could not be stored |

See [GitHub integration](/agents/github) for the workflow.
