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. {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.
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 "[email protected]"| 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 |
aetherfy.yaml must sit at the archive root: it is the whole description of how
the release runs (runtime, type, memory, always-on, idle timeout, regions, schedule,
spawn), and a field it leaves out takes its default, never the agent’s previous
value. The release records that configuration, and a later rollback or redeploy of
it runs exactly that again. An archive without it is refused with 422
DEPLOYMENT_CONFIG_PARSE_ERROR and the sentence “Missing required aetherfy.yaml.
Add it beside your code with at least runtime:; every field it leaves out takes
its default.” A file naming name, description or workspace is refused the
same way. See the aetherfy.yaml reference.
The agent’s workspace, name, description, grants and secrets are the platform’s, not the file’s: a deploy goes to the agent’s own workspace and never moves it.
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.
curl -s -X POST "https://agents.aetherfy.com/api/v1/agents/reporter/deploy?confirm_overage=true" \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-F "[email protected]"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; a deploy goes through only if it reduces cost and grows nothing — see Deploying while growth is frozen. detail.field names the first setting that would grow, and the message ends “While your account is frozen a deploy must reduce cost and grow nothing: turn always-on off, lower memory, use fewer regions or shorten the idle timeout.” |
DUNNING_FROZEN | 403 | A payment failed and the account is in its grace period. The same reduce-only rule applies |
OVERAGE_CONFIRM_REQUIRED | 402 | Confirm the added cost — see above |
PLAN_LIMIT_EXCEEDED | 403 | Agent count, memory, regions, always-on or the runtime exceeds the plan. detail.limit names which; for runtime: dockerfile on a plan without custom Dockerfile builds it is dockerfile_runtime_allowed: “Your plan does not support custom Dockerfile builds. Switch to a managed runtime or upgrade.” Nothing is built |
AGENT_SAFETY_LIMIT_EXCEEDED | 403 | The account already holds the most deployed agents any account may have (100). It is not a plan limit, so there is nothing to upgrade to: delete or archive a deployed agent, or contact support. detail.limit is agent_safety_limit and detail.support_url points to support |
DEPLOY_REGIONS_NOT_IN_SCOPE | 403 | Requested regions are outside the workspace’s region set |
INVALID_REGION | 400 | A regions: entry is not a region Aetherfy runs in. The body’s regions array names the bad ones |
WORKSPACE_HAS_NO_REGIONS | 400 | The agent’s workspace has no regions yet, so there is nowhere to deploy. Set the workspace’s regions first |
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 missing, malformed or invalid, or names name, description or workspace. The message says which, and where a platform field is set instead |
RUNTIME_IMMUTABLE | 422 | An agent’s runtime cannot change after its first deploy |
WORKER_NOT_FOUND | 422 | A name in spawn.workers is not a live agent on your account. ["*"] names none and is not checked |
AGENT_SPAWN_WORKERS_REQUIRED | 422 | spawn.enabled is true and the file lists no spawn.workers. List them, or ["*"] for any |
DEPLOYMENT_UPLOAD_FAILED | 500 | Aetherfy could not store the archive. Retry |
The Dockerfile rule holds on every path that makes a deployment, not only this
upload: a redeploy, a rollback to a Dockerfile release, a move, and a run, spawn or
scheduled task are refused with the same 403, and a push to a linked repository ends
as a failed deployment whose error_message is the same sentence. See
the Dockerfile runtime.
The agent-count limit is enforced here, not at
agent creation — 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.
What an Aetherfy agent can be deployed with
GET /api/v1/deploy-options → 200 lists the regions, runtimes and memory sizes an
agent can declare in aetherfy.yaml, each with whether it is allowed. With
?workspace_id=, each region’s allowed is the deploy’s own verdict for an agent in
that workspace: your plan and the workspace’s regions. Without it, allowed is your plan’s verdict
alone. afy init and the dashboard’s configurator offer what it allows.
curl -s "https://agents.aetherfy.com/api/v1/deploy-options?workspace_id=$WORKSPACE_ID" \
-H "Authorization: Bearer $AETHERFY_API_KEY"{
"regions": [
{"code": "us-east-1", "allowed": true},
{"code": "eu-central-1", "allowed": true},
{"code": "ap-southeast-1", "allowed": false}
],
"workspace_id": "d4b1c8f7-2e05-4a96-8b13-7f0c6e29a5d3",
"runtimes": ["python3.11", "python3.12", "python3.13", "node20", "node22", "node20-ts", "node22-ts", "bun", "dockerfile"],
"max_regions": 3,
"memory_mb": [
{"memory_mb": 256, "allowed": true},
{"memory_mb": 512, "allowed": true},
{"memory_mb": 1024, "allowed": true},
{"memory_mb": 2048, "allowed": true},
{"memory_mb": 4096, "allowed": true},
{"memory_mb": 8192, "allowed": false}
],
"max_memory_mb": 4096
}A workspace_id that is not one of your workspaces is 404 WORKSPACE_NOT_FOUND,
the same answer for another account’s id as for one that does not exist.
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.
curl -s https://agents.aetherfy.com/api/v1/agents/reporter/deployments \
-H "Authorization: Bearer $AETHERFY_API_KEY"{
"id": "0b7d1f66-3a9e-4c25-9f10-77c2b4e51a08",
"agent_id": "6f1c2b7e-0a2d-4f8e-9c31-2b0d5a7e4411",
"version": 7,
"release_version": null,
"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,
"result": null,
"result_error": null,
"has_result": false,
"created_at": "2026-08-19T09:04:11Z",
"started_at": "2026-08-19T09:04:19Z",
"deployed_at": "2026-08-19T09:06:02Z",
"error_message": null,
"build_failure_detail": null
}Aetherfy deployment states
| State | Meaning |
|---|---|
queued | Waiting for a builder. queue_position is set |
building | Building the image |
deploying | Built; machines coming up, then health-checked until each answers (up to 5 minutes). The version it replaces keeps serving meanwhile |
active | Serving |
failed | Terminal. Read error_message, and build_failure_detail when the build stage is what failed |
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 |
A deployment reads the agent’s secrets once, before any machine exists, and every
machine it creates gets the same set. If they cannot be read, the read is retried in place,
four attempts 1, 2 and 4 seconds apart; the deployment is never queued again. When
every attempt fails it ends failed with nothing built, and error_message reads
“Couldn’t read this agent’s secrets, so it wasn’t deployed. Retry the deploy.” It
carries no code: deploy again. A region still pending stays pending and retries on
its own schedule. See Secrets.
completed only appears on ephemeral deployments: the one-shot runs produced by
a scheduled task, a manual run, or a spawn. A long-lived service
deployment never reaches it.
release_version is set on a run: the version of the release it executed,
which is not the run’s own version — that is only its place in the agent’s
history. It is null on a release, on a service run no machine accepted, and on
runs recorded before Aetherfy kept it.
is_serving is true exactly when the deployment is active: every machine it
started has answered its health check. While a new deployment is deploying,
the version it replaces is the one serving. An active deployment can still be
missing a region that failed to launch, 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.
result carries what an ephemeral run returned, and result_error says why it
did not — see the result path. Both are empty on a
normal deployment and on a run that returned nothing. The two list routes above
omit result and send has_result instead: a list can hold hundreds of runs,
so read the value from GET /api/v1/deployments/{deployment_id} for the one run
you want it from.
| 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.
curl -s -X POST https://agents.aetherfy.com/api/v1/agents/reporter/deployments/6/rollback \
-H "Authorization: Bearer $AETHERFY_API_KEY"Rollback re-deploys the version’s built image when it still exists, and otherwise rebuilds the version from its stored code archive. It creates a new version rather than reviving the old number. A version with neither is not a valid target, and neither is any version of an archived agent: restore the agent first.
| Code | HTTP | Meaning |
|---|---|---|
DEPLOYMENT_ROLLBACK_TARGET_INVALID | 422 | That version is not a rollback target — it has neither its image nor its code archive. 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 |
AGENT_ALREADY_ARCHIVED | 409 | The agent is archived. Run afy restore <agent> first |
SOFT_CAP_EXCEEDED / DUNNING_FROZEN | 403 | The account is frozen and this release would not reduce cost — see Deploying while growth is frozen |
See 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.
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, with no build. Neither carries old secrets. Every new deployment, a redeploy or a rollback included, reads the agent’s secrets as they are now, once, before it creates any machine; an image never holds secrets. So redeploying, or rolling back, is how a stored secret reaches a running service. A redeploy creates a new version.
A release is its code and the configuration it was deployed with, so a redeploy — like
a rollback — runs that version’s own memory, always-on, regions and the rest, not the
agent’s latest. A version from before Aetherfy kept each release’s configuration is
refused with RELEASE_CONFIG_NOT_ON_RECORD: deploy it again from its source.
Archives are not kept forever: Aetherfy stores them for the ten most recent successful
deployments (and always for the one the agent is running), and a version whose build
fails keeps none. An archive another live or in-flight deployment still uses is kept
while it does, whichever version it was uploaded for. A version an exact rollback
created has no archive of its own: it is rebuilt from the archive of the version it
rolled back to. can_redeploy on the deployment
object tells you whether a given version still has its source; read it rather than
inferring from the state. It is false for every version of an archived agent, which
must be restored before it can be redeployed.
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 building it again, with the agent’s current
secrets, when you have no newer code to deploy.
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 |
AGENT_ALREADY_ARCHIVED | 409 | The agent is archived. Run afy restore <agent> first |
SOFT_CAP_EXCEEDED / DUNNING_FROZEN | 403 | The account is frozen and this release would not reduce cost — see Deploying while growth 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 it stops at its next
checkpoint, including during the health check that ends deploying. The machines
it started are removed and the version it was replacing keeps serving. A deployment
that already reached a terminal state cannot be cancelled.
Aetherfy also stops a deployment itself when the agent is paused, or the account
is frozen (a spend limit reached, or a payment overdue), before the new version
goes live: it ends failed with cancellation_reason agent_held or
account_frozen and an error_message saying which, its machines are removed,
and the version it was replacing is unchanged. If the agent is paused, or the
account’s agents are paused for usage, just after the new version passed its
health check, that version goes active but stopped, and it is the one that runs
when the agent resumes. A freeze alone (a payment overdue, or a spend limit that
only blocks new deploys) does not stop it: it goes active and runs, like the
account’s other agents.
A stopped deployment carries one cancellation_reason, the first one recorded,
and an error_message that says the same thing in a sentence. Switch on the value,
not on the message:
cancellation_reason | Set when |
|---|---|
user_cancel | You cancelled it with the route above |
agent_deleted | Its agent was deleted, or was being deleted, before it finished. Nothing is left running |
agent_archived | Its agent was archived, or was being archived, before it could start a machine. Nothing is left running |
parent_deleted | A spawned run whose parent agent was deleted. Nothing is left running |
parent_archived | A spawned run whose parent agent was archived. Nothing is left running |
agent_held | Its agent was paused, or stopped at its spend limit, before the new version served. The version it was replacing is unchanged |
account_frozen | The account reached its spend limit, or has a payment overdue, before the new version served. The version it was replacing is unchanged |
| 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:
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 is the deployment id — the same value — so list the
runs and pass one straight through:
# 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 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 |
Waiting for an Aetherfy run to finish
GET /api/v1/deployments/{deployment_id}/wait → 200, the same deployment object
as the read above.
Aetherfy holds the request until the run has finished, then answers immediately. This is how a caller collects a run’s result without polling in a loop.
?timeout_seconds= is how long Aetherfy may hold it: 1 to 60, default 30.
curl -s "https://agents.aetherfy.com/api/v1/deployments/$RUN_ID/wait?timeout_seconds=45" \
-H "Authorization: Bearer $AETHERFY_API_KEY"A timeout is not an error. If the run has not finished in time, Aetherfy
answers 200 with the run exactly as it stands, and its state is still active.
Read state: a terminal one means the run is over and result is final,
active means call again. Waiting longer than 60 seconds is a second call, not a
bigger number — the request is held open, and anything longer is cut by the
network in front of Aetherfy.
The same route works on a normal deployment, where “finished” means the version
reached active.
| Code | HTTP | Meaning |
|---|---|---|
DEPLOYMENT_WAIT_TIMEOUT_INVALID | 422 | timeout_seconds was outside 1 to 60 |
DEPLOYMENT_NOT_FOUND | 404 | Unknown id |
DEPLOYMENT_ACCESS_DENIED | 403 | The deployment’s agent is not yours |
The last two are exactly what the deployment read above answers, and deliberately so: the two routes return the same object, so they refuse the same way.
The complete build log is not available from the Aetherfy API
There is no endpoint for a complete build log. 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 short reason in
error_message, and the step it failed on in build_failure_detail: the tail of the
build output, redacted and capped. That field is null when the deployment failed
anywhere other than the build stage, so its absence is an answer rather than a gap.
For an agent’s runtime output, which is a different thing entirely and does have
a route, see GET /agents/{agent}/logs.
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.
POST /api/v1/agents/{agent}/github → 201
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. The owner is the account one of your installations is on, which is an organization as often as a person and need not be your own login. GET /api/v1/auth/github/repositories returns exactly the values this accepts, across every connected account; Aetherfy records which installation answered for the repository and deploys this agent through it from then on. |
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:
{
"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, account_connected, installation_id, account_login, branch_deleted_at, waiting_push} — no secret. Every agent object
carries the same object as github, so a client that already reads the agent needs
no second request for its link.
branch_deleted_at is when Aetherfy last saw the tracked branch deleted, or
null. It is set from the push webhook GitHub sends for a deletion and cleared by
the next push to that branch. It is the only place that fact is reported: a
deletion carries no commit, so there is nothing for Aetherfy to attach a check
to. Null means no deletion has been seen, which covers both an agent that
never lost its branch and one that got it back.
waiting_push is the push waiting for the deploy in progress, as {sha, received_at}, or null when none waits. An agent deploys one push at a time: a
push that arrives while one of its deployments is queued, building or deploying
waits, and has no deployment until it starts, so this is the only place the API
shows it. Where several wait, it is the one that will deploy, the newest commit;
it starts when the deploy in progress ends
(pushing while a deploy is running).
account_connected answers what linked cannot: whether a push would actually
deploy. A link survives a disconnect on purpose, so that reconnecting resumes
deployment without relinking every agent, which leaves a real state where
linked is true and pushes are skipped. When it is false, reconnect the
account; relinking the agent is the wrong remedy and fails for the same reason.
It is per AGENT, not per Aetherfy account. One Aetherfy account can hold several
GitHub installations, and an agent deploys through the one whose repositories
include its own — so disconnecting an organization leaves the agents on your
personal repositories deploying. installation_id and account_login name that
installation and its GitHub account, which is what lets a client say WHICH
account to reconnect. Both are null when the agent is not linked.
DELETE /api/v1/agents/{agent}/github → 204 unlinks and removes the webhook.
The Aetherfy webhook receiver answers 204 once the delivery is recorded, which
is before the deploy starts: the code means Aetherfy has the push, not that it
has deployed it. What that buys, and why redelivering is always safe, is on
GitHub integration.
| Code | HTTP | Meaning |
|---|---|---|
GITHUB_NOT_CONNECTED | 422 | Connect the account first — see Account and usage |
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 |
GITHUB_DELIVERY_NOT_RECORDED | 500 | A verified push delivery could not be recorded, so Aetherfy refused it rather than accepting a push it might not finish. Redeliver it from the repository’s webhook settings |
See GitHub integration for the workflow.