---
slug: agents/api-workspaces
title: Workspaces and regions API
kind: reference
surface: agents
summary: REST reference for Aetherfy workspaces and regional placement — creating and managing workspaces, listing their agents, changing a workspace's region set, moving a collection between workspaces, and polling the long-running operation both of those return.
sources:
  - aetherfy-control-plane:api/routes/workspaces.py
  - aetherfy-control-plane:api/routes/regions.py
  - aetherfy-control-plane:api/routes/collections.py
  - aetherfy-control-plane:shared/error_codes.py
---

# 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](/agents/api).

A workspace groups agents and collections and gives them a shared region set and
shared secrets. For the concept, see [Workspaces](/platform/workspaces).

## Creating an Aetherfy workspace

`POST /api/v1/workspaces` → 201

```bash
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[] \| null | Optional. Omit to inherit your account's default placement |

Omitting `regions` is the normal case: Aetherfy resolves the workspace to your
account's default placement rather than leaving it unset. Supplying an **empty array**
is a different thing and is rejected — a workspace with no regions could hold nothing.

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](/platform/regions).

```json
{
  "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_EMPTY` | 422 | `regions` was an empty array. Omit it instead |
| `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.

```bash
curl -s -X PATCH https://agents.aetherfy.com/api/v1/workspaces/research \
  -H "Authorization: Bearer $AETHERFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"Research agents, 2026"}'
```

`PATCH` changes `description` only in practice. **A workspace name is immutable**: you
may send `name`, but it has to equal the name already in the URL, and anything else is
refused with 400 `WORKSPACE_NAME_IMMUTABLE`. To rename, create a new workspace and
move resources into it.

Regions are not changed here either — they have their own route below.

## Listing the agents in an Aetherfy workspace

`GET /api/v1/workspaces/{workspace}/agents` → 200

```json
[
  {
    "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 and were once one field.

A summary shape, not the full agent object — read
[`GET /agents/{agent}`](/agents/api-lifecycle) for that.

`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.

## Deleting an Aetherfy workspace

`DELETE /api/v1/workspaces/{workspace}` → 200

```json
{"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.

`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 | Move or delete its agents first |
| `WORKSPACE_HAS_COLLECTIONS` | 409 | Move or delete its collections first |

## Changing an Aetherfy workspace's regions

`PATCH /api/v1/workspaces/{workspace}/regions` → 202

```bash
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.

```json
{
  "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:

```json
{"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 replicates the workspace's collections into it, and Aetherfy counts
every replica against your storage quota — so a widen can be refused with
`STORAGE_LIMIT_EXCEEDED` for space rather than for placement. Check headroom before
widening a large workspace.

| Code | HTTP | Meaning |
|---|---|---|
| `WORKSPACE_NOT_FOUND` | 404 | |
| `USER_NOT_FOUND` | 404 | |
| `STORAGE_LIMIT_EXCEEDED` | 422 | The new replicas would exceed your storage quota |
| `INVALID_WORKSPACE_REGIONS` | 400 | Unknown region, or more than your plan allows |
| `REGION_OP_IN_FLIGHT` | 409 | A region change is already running for this workspace |
| `WORKSPACE_NARROW_BLOCKED_BY_RESOURCES` | 422 | Narrowing would strand resources that still use a region you are removing |

`WORKSPACE_NARROW_BLOCKED_BY_RESOURCES` is the **narrow** counterpart to
`STORAGE_LIMIT_EXCEEDED`: it 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`, plus an `agent_conflicts` array and a `collection_conflicts`
array, each entry giving the resource name and the regions it currently holds:

```json
{"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"],
            "agent_conflicts": [{"name": "pdf-parser", "deployment_regions": ["iad", "sin"]}],
            "collection_conflicts": [{"name": "documents", "regions": ["iad", "sin"]}]}}
```

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
[`PATCH /users/me/home-region`](/agents/api-account) — same code, different position
in the envelope, because that route validates a single region rather than a set.

## Moving an Aetherfy collection between workspaces

`PATCH /api/v1/collections/{collection_id}` → 200 or 202

```bash
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 \| null | Target workspace, or `null` to move the collection out of any workspace |
| `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](/vectors/api).

Send `workspace_id` **or** `regions`, not both: combining them is refused, because the
workspace already implies a placement and the two could disagree. When the move needs
data to relocate, the answer is 202 with an `operation_id`; when nothing has to move,
it is 200.

**Aliases do not survive the move.** Aetherfy deletes the collection's aliases in the
source scope and does not recreate them in the target. The completed operation lists
what went in `deleted_aliases` so you can recreate them deliberately.

| Code | HTTP | Meaning |
|---|---|---|
| `COLLECTION_NOT_FOUND` | 404 | |
| `TARGET_WORKSPACE_NOT_FOUND` | 404 | |
| `COLLECTION_MOVE_IN_FLIGHT` | 409 | A move is already running for this collection |
| `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 target workspace's set |
| `TARGET_WORKSPACE_HAS_NO_REGIONS` | 422 | The target workspace has no region set to inherit |
| `VALIDATION_VIOLATIONS` | 400 | Several rules failed at once. Iterate `detail.violations` |
| `COLLECTION_MOVE_ENQUEUE_FAILED` | 500 | The move could not be queued. Retry |

`VALIDATION_VIOLATIONS` is the accumulating envelope: rather than failing on the first
problem, Aetherfy reports every rule the request broke in one `violations` array, each
entry carrying its own `code`. Read that array rather than the top-level `message`.

## 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.

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

```json
{
  "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,
  "deleted_aliases": 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 an array of structured per-step failures when a saga fails, falling back
to a single message when there is no structured detail. `deleted_aliases` is populated
on a collection move and `null` on a region change.

Returns 404 `OPERATION_NOT_FOUND` for an unknown id or one belonging to another
account.
