Aetherfy workspaces and regions API
Base URL https://agents.aetherfy.com/api/v1, bearer authentication, and the shared
error envelope are described on the Agent REST API index.
A workspace groups agents and collections and gives them a shared region set and shared secrets. For the concept, see Workspaces.
Creating an Aetherfy workspace
POST /api/v1/workspaces → 201
curl -s -X POST https://agents.aetherfy.com/api/v1/workspaces \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"research","description":"Research agents","regions":["iad","fra"]}'| Field | Type | Notes |
|---|---|---|
name | string | Required. 3–63 characters, unique per account |
description | string | null | Optional |
regions | string[] | Required. The workspace’s regions: 1 up to your plan’s maximum, each a region Aetherfy runs in |
A workspace owns its regions, and you name them when you create it: nothing is chosen
for you. Omitting regions is refused with 422 WORKSPACE_REGIONS_REQUIRED, whose body
carries allowed_regions (the regions Aetherfy runs in) and max_regions (your plan’s
limit per workspace). An empty array is a different mistake, 422
WORKSPACE_REGIONS_EMPTY: a workspace with no regions could hold nothing.
Every account starts with a workspace named default, and keeps at least one.
Workspace names are unique within the account; collection names are unique within
a workspace.
The region set must fit your plan’s maximum. On Free and Starter that maximum is one, so a multi-region workspace requires the Performance plan — see Regions and replication.
{
"id": "d4b1c8f7-2e05-4a96-8b13-7f0c6e29a5d3",
"name": "research",
"description": "Research agents",
"regions": ["iad", "fra"],
"agent_count": 0,
"created_at": "2026-08-19T09:30:00Z",
"updated_at": "2026-08-19T09:30:00Z"
}| Code | HTTP | Meaning |
|---|---|---|
WORKSPACE_NAME_TAKEN | 409 | |
WORKSPACE_LIMIT_EXCEEDED | 400 | Your plan’s workspace count is reached |
WORKSPACE_REGIONS_REQUIRED | 422 | regions was omitted. Names allowed_regions and max_regions |
WORKSPACE_REGIONS_EMPTY | 422 | regions was an empty array |
INVALID_WORKSPACE_REGIONS | 400 | A region code is unknown, or there are more than your plan allows |
USER_NOT_FOUND | 404 |
Reading and updating an Aetherfy workspace
GET /api/v1/workspaces → 200, an array.
GET /api/v1/workspaces/{workspace} → 200, one workspace.
PATCH /api/v1/workspaces/{workspace} → 200.
The path parameter is the workspace name, not its UUID.
curl -s -X PATCH https://agents.aetherfy.com/api/v1/workspaces/research \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"research-2026","description":"Research agents, 2026"}'PATCH renames a workspace, changes its description, or both; an omitted field is
untouched. Everything refers to a workspace by its id, so a rename changes nothing in
it: its agents, collections, grants and secrets stay where they are, running agents
are not redeployed, and their AETHERFY_WORKSPACE_ID does not change. From then on
the workspace answers to the new name only, in this API, the CLI and the
vector API: code that addresses it by the old name stops finding it,
and an agent using workspace="auto" is unaffected, because that is by id.
| Code | HTTP | Meaning |
|---|---|---|
WORKSPACE_NOT_FOUND | 404 | |
WORKSPACE_NAME_TAKEN | 409 | Another workspace of the account already has that name |
Regions are not changed here — they have their own route below.
Listing the agents in an Aetherfy workspace
Two lists answer “which agents are here?”: the agents that live in the workspace, and the agents of your other workspaces that were granted access to it.
GET /api/v1/workspaces/{workspace}/agents → 200
[
{
"id": "6f1c2b7e-0a2d-4f8e-9c31-2b0d5a7e4411",
"name": "reporter",
"agent_type": "job",
"status": "running",
"deployed": true,
"url": null,
"is_degraded": false,
"regions_total": 2,
"regions_ready": 2,
"degraded_reason": null,
"created_at": "2026-08-19T09:04:00Z",
"updated_at": "2026-08-19T09:06:02Z"
},
{
"id": "9d3f8a1c-77b2-4e60-8a15-c4e9f2d61b03",
"name": "api",
"agent_type": "service",
"status": "running",
"deployed": true,
"url": "https://api-m4t7q3.aetherfy.dev",
"is_degraded": false,
"regions_total": 2,
"regions_ready": 2,
"degraded_reason": null,
"created_at": "2026-08-19T09:04:00Z",
"updated_at": "2026-08-19T09:06:02Z"
}
]Both agents are deployed; only one has a url. A service listens for
requests and therefore has an address. A job runs to completion and exits —
its image is not a web server, so there is nowhere to send a request and url
is null. Read deployed to ask whether an agent is up; read url to ask
where to reach it. They are different questions.
A summary shape, not the full agent object — read
GET /agents/{agent} for that. These are the agents that
live in the workspace; the ones granted access to it are listed next.
deployed and url answer two different questions, and the example above is
the case that shows why. deployed is whether the agent holds a running app —
it is what the plan’s agent quota counts, and what archive, pause and run-now
are offered on. url is where to send a request, and this agent has none: a
job runs once and exits behind no HTTP server. Read deployed to tell whether
an agent is running; read url only when you want to call one.
serves_websocket is a third read-only field, and it is derived rather than
declared: Aetherfy reads what your deployed app actually serves — the live
route table on the Python runtimes, the live upgrade handlers on node and bun
— and reports it back. You never set it.
It has three values, and the third is the one worth knowing. true and
false mean the agent answered and does or does not serve WebSocket routes.
null means Aetherfy has no usable answer yet, which is not the same as
false — a newly created agent, or one that has been asleep since it
deployed, has told us nothing. Branch on === true rather than on
truthiness if the difference matters to you.
Note that it says what an agent ADDITIONALLY accepts. Every service agent
serves HTTP, because Aetherfy health-checks GET /health on all of them, so
serves_websocket: true means HTTP and WebSocket rather than one instead of
the other.
The agents granted access to an Aetherfy workspace
GET /api/v1/workspaces/{workspace}/granted-agents → 200
[
{
"id": "3b7a9c52-1d4e-4f60-8a2b-9e5c7d1f0a64",
"name": "crawler",
"granted_at": "2026-10-12T08:15:00Z"
}
]The agents of your other workspaces that were granted this one, ordered by name,
each with when the grant was made. A grant gives an agent’s key access to this
workspace’s collections; it does not move the agent, and an agent that lives here is
never in this list (it is in the one above). An agent being deleted is left out. An
empty list is []. Grants are made and removed with
/agents/{agent}/access/workspaces.
| Code | Status | Meaning |
|---|---|---|
WORKSPACE_NOT_FOUND | 404 | The path names no workspace on your account |
Deleting an Aetherfy workspace
DELETE /api/v1/workspaces/{workspace} → 200
{"status": "deleted", "secrets_deleted": 3}Deletion is refused while the workspace still holds anything, and the response tells you which kind of thing is blocking it. Move or delete those first. Every account keeps at least one workspace, so its last one cannot be deleted.
secrets_deleted reports how many workspace secrets went with it — they are the one
thing deletion does remove, since nothing else can reach them.
| Code | HTTP | Meaning |
|---|---|---|
WORKSPACE_HAS_AGENTS | 409 | Live agents remain: move or delete them first. agent_count is the number of live agents |
WORKSPACE_AGENTS_STILL_DELETING | 409 | Only agents still being deleted remain: retry once their deletion finishes. agent_count is the number being deleted |
WORKSPACE_HAS_COLLECTIONS | 409 | Move or delete its collections first |
WORKSPACE_IS_LAST | 409 | It is the account’s only workspace |
Changing an Aetherfy workspace’s regions
PATCH /api/v1/workspaces/{workspace}/regions → 202
curl -s -X PATCH https://agents.aetherfy.com/api/v1/workspaces/research/regions \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"regions":["iad","fra","sin"]}'regions is the desired final set, not a delta. Aetherfy computes what to add and
remove.
{
"operation_id": "f2a7c390-4e81-4b25-9d06-8c1f5e0b7a43",
"from_regions": ["iad", "fra"],
"to_regions": ["iad", "fra", "sin"],
"regions_to_add": ["sin"],
"regions_to_remove": [],
"workspace_name": "research"
}This is asynchronous. The 202 hands you an operation_id to poll rather than blocking
until every collection has replicated — see below.
A no-op answers 200, not 202, and has no operation_id. If the set you send already
equals the current one, Aetherfy enqueues nothing and returns a different body:
{"status": "no_op", "scope": "workspace", "current_regions": ["iad", "fra"], "workspace_name": "research"}The comparison is by set, so reordering the same regions is also a no-op. Branch on the
status code — or on the presence of operation_id — before trying to poll, because
polling a no-op has nothing to poll.
The change affects the workspace’s collections and agents together. Widening the set makes a region available; getting a running agent into it still requires a redeploy.
Adding a region does not copy anything into it: the workspace’s collections keep the regions they have, and its agents keep the regions they were deployed to. So a widen adds no storage and is never refused for space; a collection’s own regions change, which does add replicas, is where the storage quota applies (changing a collection’s regions). A workspace’s regions are the ceiling for what is in it, not a placement.
| Code | HTTP | Meaning |
|---|---|---|
WORKSPACE_NOT_FOUND | 404 | |
USER_NOT_FOUND | 404 | |
VALIDATION_VIOLATIONS | 400 | One or more checks failed — an unknown region (INVALID_REGION), more regions than your plan allows, or a component not ready. Iterate detail.violations |
REGION_OP_IN_FLIGHT | 409 | A region change is already running for this workspace |
WORKSPACE_REGIONS_EMPTY | 422 | regions was an empty array: a workspace cannot be left without regions |
WORKSPACE_NARROW_BLOCKED_BY_RESOURCES | 422 | Narrowing would strand resources that still use a region you are removing |
WORKSPACE_NARROW_BLOCKED_BY_RESOURCES refuses the change rather than shrinking
anything, so a narrow never deletes data as a side effect. Its body names every blocker at once —
regions_to_remove, regions_in_use (the removed regions something still uses), plus an
agent_conflicts array and a collection_conflicts array, each entry giving the resource name and the regions it currently holds. A
collection conflict also carries the collection’s id:
{"detail": {"code": "WORKSPACE_NARROW_BLOCKED_BY_RESOURCES",
"message": "This region change removes ['sin'], but 2 resource(s) still use a region being removed. ...",
"regions_to_remove": ["sin"],
"regions_in_use": ["sin"],
"agent_conflicts": [{"name": "pdf-parser", "deployment_regions": ["iad", "sin"]}],
"collection_conflicts": [{"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "name": "documents", "regions": ["iad", "sin"]}]}}A collection that is being moved into the workspace counts as already in it.
Clear each conflict first — redeploy the listed agents without the region, and PATCH
each listed collection to drop it — then retry the narrow. Note this one is a
top-level detail.code, not a pre-flight violation entry: it is checked after the
validations below pass, so you will not see it in a detail.violations array.
Pre-flight violations on an Aetherfy region change
Before enqueueing, Aetherfy checks the whole change and reports every problem
at once rather than failing on the first. That answer is 400 with the top-level code
VALIDATION_VIOLATIONS and a detail.violations array — each entry carrying its own
code. Read the array, not the top-level code, or you will see one failure and fix
one of four.
Violation code | Meaning |
|---|---|
INVALID_REGION | Not a region Aetherfy runs in. One entry per bad region, each naming it |
PLAN_REGION_LIMIT | More regions than your plan allows for this scope. Names the limit and what you asked for |
REGION_UNAVAILABLE | The region is temporarily closed to new placements. Transient — retry later |
COMPONENT_NOT_READY | A collection or agent in the workspace is mid-transition. Names it and its state; wait for a stable one |
AGENT_REGION_STRANDED | An agent has a live deployment in a region this change would remove. Lists deployment_regions, target_regions and stranded_regions — redeploy the agent narrower, or widen the target set |
INVALID_REGION is the one code here that also appears at the top level, on the
deploy, rollback and redeploy routes — same code, different position in the envelope,
because those routes validate a single region in aetherfy.yaml rather than a set.
Moving an Aetherfy collection between workspaces
PATCH /api/v1/collections/{collection_id} → 200 or 202
curl -s -X PATCH https://agents.aetherfy.com/api/v1/collections/$COLLECTION_ID \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"workspace_id":"d4b1c8f7-2e05-4a96-8b13-7f0c6e29a5d3"}'| Field | Type | Notes |
|---|---|---|
workspace_id | uuid | The workspace to move the collection to. Every collection lives in one |
regions | string[] | null | An explicit region set for the collection |
The path parameter is the collection’s UUID, not its name — this is a control-plane route, unlike the name-addressed collection routes on the vector API.
Send workspace_id or regions, not both: combining them is refused, because the
two are checked against each other. regions changes where the collection’s data is;
workspace_id changes where the collection is reachable from.
A collection belongs to exactly one workspace, and that decides where it is
reachable from: requests through that workspace reach it, requests through another
do not. workspace_id may not be null (422 VALIDATION_ERROR). Collection names
are unique per workspace, so a move is refused, before anything is queued, when
the name is already taken in the target: by a collection (COLLECTION_NAME_TAKEN,
naming existing_collection_id), by an alias (COLLECTION_NAME_IS_AN_ALIAS), or
when one of the collection’s own aliases clashes there (ALIAS_NAME_TAKEN, listing
alias_names). Rename one of them first.
A move changes only the workspace. The collection keeps its regions, and its data
stays where it is: nothing is copied or deleted, and your storage use does not change.
So each region the collection is placed in must be one of the target workspace’s
regions. A collection placed anywhere else is refused with COLLECTION_MOVE_OUTSIDE_TARGET_REGIONS, naming
the regions that do not fit in regions_outside_target. Change the collection’s
regions first, or add those regions to the target workspace, then move it.
A move answers 202 with an operation_id; asking for the workspace it is already in
answers 200 with "no_op": true. When the operation succeeds, the collection answers
through its new workspace and no longer through the old one.
{
"operation_id": "8c3e5a19-7f20-4d6b-a1e4-0b97c2d5f816",
"collection_id": "3a9f6e2c-51d8-4b07-9c14-e8d2f0a7b635",
"source_workspace_id": "a71e0c94-3b6d-4f28-9e15-6d2b8c0f4e17",
"target_workspace_id": "d4b1c8f7-2e05-4a96-8b13-7f0c6e29a5d3",
"regions": ["iad"]
}Aliases move with the collection. An alias belongs to its collection, so after the move it answers in the target workspace, pointing at the same collection, and no longer in the old one.
The memory SDK’s conversation collection is never moved. A MemoryClient keeps
the conversations of every agent living in a workspace in that workspace’s
__threads__ collection. Moving it would hand those agents’ conversations to another
workspace’s agents, so it is refused with COLLECTION_SDK_MANAGED. A MemoryClient
in the target workspace keeps its own.
| Code | HTTP | Meaning |
|---|---|---|
COLLECTION_NOT_FOUND | 404 | |
TARGET_WORKSPACE_NOT_FOUND | 404 | |
USER_NOT_FOUND | 404 | |
COLLECTION_MOVE_IN_FLIGHT | 409 | A move or a regions change is already running for this collection |
COLLECTION_NAME_TAKEN | 409 | The target workspace already has a collection of that name. Rename one of them first |
COLLECTION_NAME_IS_AN_ALIAS | 409 | The name is an alias in the target workspace |
ALIAS_NAME_TAKEN | 409 | One of the collection’s aliases, which move with it, is already a name in the target workspace. alias_names lists them |
REGION_OP_IN_FLIGHT | 409 | A region change is running on the target workspace |
COLLECTION_REGIONS_WORKSPACE_COMBINED | 400 | Both workspace_id and regions were sent |
COLLECTION_REGIONS_EMPTY | 422 | regions was an empty array |
COLLECTION_REGIONS_NOT_IN_SCOPE | 422 | The requested regions are outside the collection’s workspace. Names candidate_regions, scope_regions, missing_regions and workspace_id |
COLLECTION_MOVE_OUTSIDE_TARGET_REGIONS | 422 | The collection is placed in a region the target does not allow. Names regions_outside_target; change the collection’s regions or widen the target workspace first |
COLLECTION_SDK_MANAGED | 422 | The collection is the memory SDK’s conversation collection, which cannot be moved |
STORAGE_LIMIT_EXCEEDED | 422 | The regions you added would put the collection’s replicas over your storage quota |
REGION_CHANGE_COUNT_LIMIT_EXCEEDED | 422 | The change copies data, and your plan’s region changes that copy data in 30 days are used up. Names limit, used, needed and frees_at |
REGION_CHANGE_SIZE_LIMIT_EXCEEDED | 422 | The change would copy more than your plan has left to copy between regions in 30 days. Names limit_bytes, used_bytes, needed_bytes and frees_at |
COLLECTION_MOVE_ENQUEUE_FAILED | 500 | The change could not be queued. Retry |
Changing an Aetherfy collection’s regions
PATCH /api/v1/collections/{collection_id} with regions changes where the
collection’s data is. A region you add gets a copy of the collection’s data; a region
you remove has its copy deleted. It answers 202 with an operation_id, and the
collection keeps serving from its current regions until the operation finishes.
A change that copies data is limited per account, over a rolling 30 days. A change copies data when it adds a region. Two limits apply, and a change past either is refused before anything is copied:
| Limit | Performance | Enterprise |
|---|---|---|
| Region changes that copy data, per 30 days | 10 | unlimited |
| Data copied between regions, per 30 days | 400 GB — your storage, times your regions minus one | unlimited |
A change that only removes regions is not counted, and neither is a move, which copies nothing. Free and Starter allow none: they are single-region, so no change of theirs can copy data. Every change that copied data in the last 30 days counts, whether it finished or failed. Its size is the collection’s stored bytes times the regions it added, as they were when the change was accepted.
A refusal names the limit, what the last 30 days have used, what this change needs,
and frees_at — when the oldest change that has to leave the window does. A change
too large for the whole size limit has frees_at: null: no window allows it, so add
fewer regions at a time. Your storage quota (STORAGE_LIMIT_EXCEEDED) is checked
first.
Previewing a regions change
POST /api/v1/collections/{collection_id}/regions/preview → 200
Asks what the change would do, without doing it. The answer comes from the same code
the change runs, so the change does what its preview says. Send the same regions:
curl -s -X POST https://agents.aetherfy.com/api/v1/collections/$COLLECTION_ID/regions/preview \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"regions":["iad","fra"]}'{
"collection_id": "3a9f6e2c-51d8-4b07-9c14-e8d2f0a7b635",
"from_regions": ["iad"],
"to_regions": ["iad", "fra"],
"regions_to_add": ["fra"],
"regions_to_remove": [],
"no_op": false,
"copy_bytes": 3221225472,
"data_deleted_from": [],
"limits": {
"counted": true,
"window_days": 30,
"count": { "limit": 10, "used": 3, "this_change": 4 },
"size": { "limit_bytes": 429496729600, "used_bytes": 53687091200, "left_after_bytes": 372588412928 }
},
"refusal": null
}refusal is null, or the answer the change would get: its status, code,
message and the refusal’s own fields. The preview answers 200 either way; it is 404
only for a collection that is not yours, and 422 COLLECTION_REGIONS_EMPTY for an
empty list. limits is null for a change that would be refused before the limits are
read, and for a no-op. An accepted change’s 202 carries the same object as plan.
Reading how much of the region-change limits you have used
GET /api/v1/users/me/region-changes → 200
The same window and limits a regions change is judged against, so the number it shows is the number that refuses:
curl -s https://agents.aetherfy.com/api/v1/users/me/region-changes \
-H "Authorization: Bearer $AETHERFY_API_KEY"{
"window_days": 30,
"count_used": 3,
"count_limit": 10,
"size_used_bytes": 53687091200,
"size_limit_bytes": 429496729600,
"frees_at": "2026-11-02T08:15:00Z"
}Only changes that copy data count. A null limit means your plan has none, and
frees_at is null when nothing is counted. afy collections region-limits prints
the same figures.
Reading the change running on an Aetherfy collection
GET /api/v1/collections/{collection_id}/operation → 200
A collection runs one regions change or move at a time. This reads the one running, whoever started it — the dashboard, the CLI, a script — so a client that did not start it can still follow it.
curl -s https://agents.aetherfy.com/api/v1/collections/$COLLECTION_ID/operation \
-H "Authorization: Bearer $AETHERFY_API_KEY"{
"collection_id": "8e3c1f52-9a47-4d0b-b6e1-2f5a7c9d0e34",
"operation": {
"operation_id": "f2a7c390-4e81-4b25-9d06-8c1f5e0b7a43",
"kind": "regions_change",
"status": "in_progress",
"from_regions": ["us-east-1"],
"to_regions": ["us-east-1", "eu-central-1"],
"target_workspace": null,
"accepted_at": "2026-10-12T09:31:02Z"
}
}kind is regions_change or move. status is pending or in_progress, the
words the operation’s poll below uses. target_workspace is a move’s destination
as an {id, name} pair, and null for a regions change; for a move, to_regions equals from_regions. operation is null
when nothing runs; follow operation_id with the poll below. A collection that is
not yours is 404 COLLECTION_NOT_FOUND.
Polling an Aetherfy long-running operation
GET /api/v1/operations/{operation_id} → 200
Both the region change and the collection move above return an operation_id. This is
where you follow it.
curl -s https://agents.aetherfy.com/api/v1/operations/$OPERATION_ID \
-H "Authorization: Bearer $AETHERFY_API_KEY"{
"operation_id": "f2a7c390-4e81-4b25-9d06-8c1f5e0b7a43",
"scope": "workspace",
"target_id": "d4b1c8f7-2e05-4a96-8b13-7f0c6e29a5d3",
"status": "succeeded",
"regions_to_add": ["sin"],
"regions_to_remove": [],
"started_at": "2026-08-19T09:31:02Z",
"completed_at": "2026-08-19T09:34:47Z",
"failures": null
}status | Meaning |
|---|---|
pending | Queued, not started |
in_progress | Running, or briefly between retries |
succeeded | Terminal, done |
failed | Terminal. failures carries the reasons |
in_progress deliberately covers the short window after a retryable sub-step failed
and before it is retried, so a transient blip does not surface as failed. Only
failed is a terminal failure.
failures is null unless status is failed. Then it holds one object per step
that did not complete, and every object has a message:
| Field | Present | Meaning |
|---|---|---|
message | Always | What did not complete, in a sentence |
sub_step | When the step was recorded | The step’s id within the operation |
type | When the step was recorded | The kind of step, for example backfill |
region | When the step was recorded | The region the step ran in |
attempts | When the step was recorded | How many times the step was tried |
collection_id | Collection steps | The collection the step acted on |
An operation that failed before it recorded which step has a single entry with
message alone.
"failures": [
{
"sub_step": "backfill-abc-eu-central-1",
"type": "backfill",
"collection_id": "abc",
"region": "eu-central-1",
"attempts": 5,
"message": "The backfill step in eu-central-1 did not complete."
}
]Returns 404 OPERATION_NOT_FOUND for an unknown id or one belonging to another
account.