# Environments API (/docs/environments/api)

The /v1/projects and /v1/environments endpoints on ai.codai.ro, with request and response examples.

Base URL `https://ai.codai.ro`, `Authorization: Bearer codai_…`, JSON in and out. The live daemon view (online, listening ports, git state, locks, attached clients) is served by the **relay** at `https://relay.codai.ro/v1/environments/{id}/status` with the same key.

Conventions, all taken from the gateway:

- Project and environment rows are returned **camelCase**, as stored; request bodies and member/secret lists are **snake_case**.
- A project or environment you cannot see answers `404` (never `403`), so ids cannot be enumerated. A role violation on something you can see answers `403`.
- Errors use the gateway envelope `{ "error": { "message", "type", "code" } }`; validation errors are `400 bad_request`.
- Every mutation appends an `environment_events` row.

## Endpoints [#endpoints]

| Method | Path | Min. role | Notes |
| --- | --- | --- | --- |
| `GET` | `/v1/projects` | — | Yours plus your orgs', newest first |
| `POST` | `/v1/projects` | — | `org_id` needs org owner/admin |
| `GET` | `/v1/projects/{id}` | visible | Project + all its environments |
| `PATCH` | `/v1/projects/{id}` | project owner / org admin | Slug cannot change |
| `DELETE` | `/v1/projects/{id}` | project owner / org admin | `409 project_has_environments` until all are destroyed |
| `GET` | `/v1/environments` | — | `?project_id=`, `?for_user_id=me` |
| `POST` | `/v1/environments` | project visible | Managed providers are feature-gated (`403`) |
| `GET` | `/v1/environments/{id}` | viewer | Env + role + isolation + members + ports + last 20 events |
| `PATCH` | `/v1/environments/{id}` | maintainer | `name`, `idle_timeout_minutes` |
| `POST` | `/v1/environments/{id}/start` · `stop` · `archive` | maintainer | `409 invalid_transition` |
| `POST` | `/v1/environments/{id}/destroy` | owner | |
| `POST` | `/v1/environments/{id}/enroll-token` | maintainer | BYO only (`409 not_byo`) |
| `POST` | `/v1/environments/enroll` | token | Called by `codaid`, no API key |
| `GET` | `/v1/environments/{id}/members` | viewer | |
| `POST` | `/v1/environments/{id}/members` | maintainer | `owner` needs owner |
| `PUT` | `/v1/environments/{id}/members/me/ssh-key` | developer | Your own key |
| `POST` | `/v1/environments/{id}/members/me/ssh-cert` | developer | `{ public_key }` → 12 h OpenSSH user cert for your Linux user (`certificate`, `principal`, `valid_before`, `host`); `503 ssh_ca_unconfigured` without a CA |
| `GET` | `/v1/environments/{id}/ssh-ca` | viewer | `{ public_keys }` of the SSH user CA the VMs trust; `404` when no CA is configured |
| `PATCH` · `DELETE` | `/v1/environments/{id}/members/{userId}` | maintainer | Leaving yourself needs no role |
| `GET` | `/v1/environments/{id}/ports` | viewer | Published ports |
| `PUT` · `DELETE` | `/v1/environments/{id}/ports/{port}` | developer | `public` needs maintainer |
| `GET` | `/v1/environments/{id}/secrets` | developer | Names only |
| `PUT` · `DELETE` | `/v1/environments/{id}/secrets/{name}` | maintainer | Names are `ENV_STYLE` |
| `GET` | `/v1/environments/{id}/tasks` | viewer | Newest 50 agent tasks |
| `POST` | `/v1/environments/{id}/tasks` | developer | `202`; env must be `running` (`409 environment_not_running`) |
| `GET` | `/v1/environments/{id}/tasks/{taskId}` | viewer | |
| `POST` | `/v1/environments/{id}/exec` | developer | One relay op, blocking; `409 environment_offline`, `504 host_timeout` |
| `GET` | `/v1/environments/{id}/files` | developer | `?path=` (default your workspace) |
| `GET` · `PUT` | `/v1/environments/{id}/files/{path}` | developer | UTF-8 text; read ≤ 512 KB, write ≤ 2 MB (`413`) |
| `POST` | `/v1/environments/{id}/jobs` | developer | Background job, returns `job_id` at once |
| `GET` | `/v1/environments/{id}/jobs/{jobId}` | developer | Status + output (`?since=`, `?tail=`) |

## Create a project [#create-a-project]

```bash
curl https://ai.codai.ro/v1/projects \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "brivio", "repo_url": "https://github.com/acme/brivio", "isolation": "shared_vm" }'
```

`201`:

```json
{
  "project": {
    "id": "2b6d1c0e-4f3a-4b9c-8d7e-1a2b3c4d5e6f",
    "ownerUserId": "5f0c…", "orgId": null,
    "name": "brivio", "slug": "brivio",
    "repoUrl": "https://github.com/acme/brivio", "defaultBranch": "main", "devcontainerPath": null,
    "isolation": "shared_vm", "settings": {},
    "createdAt": "2026-09-25T09:00:00.000Z", "updatedAt": "2026-09-25T09:00:00.000Z"
  }
}
```

`slug` defaults to the slugified name and must be unique among your projects (`409 slug_taken`). `isolation` is `shared_vm` (default), `container` or `vm_per_dev`.

## Create an environment [#create-an-environment]

| Field | Type | Notes |
| --- | --- | --- |
| `project_id` | uuid | Required. |
| `provider` | `byo` · `openstack` · `hetzner` | Required. `local` is reserved. |
| `name` | string ≤ 120 | Required, except in `vm_per_dev` projects (defaults to `<project> — <you>`). |
| `slug` | `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` | Default `<project-slug>-<4 random chars>`; globally unique (`409 slug_taken`). |
| `size` | `small` · `medium` · `large` | Managed only; default `small`. |
| `region` | string | Managed only; default `eu-east-1` (OpenStack) or `nbg1` (Hetzner). |
| `idle_timeout_minutes` | 5–1440 | Default 30. |
| `budget_micro_eur` | integer ≥ 0 or `null` | Managed only; monthly compute cap. |

```bash
curl https://ai.codai.ro/v1/environments \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "project_id": "2b6d1c0e-4f3a-4b9c-8d7e-1a2b3c4d5e6f", "name": "dev-box", "provider": "byo" }'
```

`201`:

```json
{
  "environment": {
    "id": "9c1e5a7b-2d4f-4e6a-8b0c-3d5e7f9a1b2c",
    "projectId": "2b6d1c0e-4f3a-4b9c-8d7e-1a2b3c4d5e6f",
    "ownerUserId": "5f0c…", "orgId": null, "forUserId": null, "for_user_id": null,
    "isolation": "shared_vm",
    "name": "dev-box", "slug": "brivio-k3xq",
    "provider": "byo", "region": null, "size": null,
    "state": "pending", "stateReason": null,
    "deviceId": null, "providerRef": null, "publicIp": null,
    "idleTimeoutMinutes": 30, "lastActivityAt": null, "startedAt": null, "stoppedAt": null,
    "hourlyRateMicroEur": null, "includedHoursUsedSeconds": 0,
    "metadata": {},
    "createdAt": "2026-09-25T09:01:00.000Z", "updatedAt": "2026-09-25T09:01:00.000Z"
  }
}
```

A managed provider starts in `creating` with `size`, `region`, `hourlyRateMicroEur` (e.g. `30000` for `small`) and `metadata.budget_micro_eur` filled in. In a `vm_per_dev` project a second live environment answers:

```json
{
  "error": { "message": "You already have an environment in this vm_per_dev project.", "type": "invalid_request_error", "code": "already_has_env" },
  "environment_id": "9c1e5a7b-2d4f-4e6a-8b0c-3d5e7f9a1b2c"
}
```

## Enrol a BYO machine [#enrol-a-byo-machine]

```bash
curl -X POST https://ai.codai.ro/v1/environments/$ENV_ID/enroll-token -H "Authorization: Bearer $CODAI_API_KEY"
```

```json
{ "token": "codai_env_4Qm9…32 chars", "expires_at": "2026-09-25T09:31:00.000Z", "environment_id": "9c1e5a7b-…" }
```

The token is returned once, stored only as a SHA-256 hash, valid 30 minutes, and minting again replaces it. `codaid` presents it to `POST /v1/environments/enroll`:

```json
{ "token": "codai_env_…", "device_id": "1c2f…-uuid", "daemon_version": "0.3.3", "os": "linux", "arch": "x86_64" }
```

→ `{ "environment_id": "9c1e…", "relay_url": "wss://relay.codai.ro" }` (managed machines also receive a one-time `api_key`). An unknown, used or expired token is `401 invalid_api_key`; a state that does not admit enrolment is `409 not_enrollable`.

## Lifecycle actions [#lifecycle-actions]

```bash
curl -X POST https://ai.codai.ro/v1/environments/$ENV_ID/stop -H "Authorization: Bearer $CODAI_API_KEY"
```

```json
{ "environment": { "id": "9c1e…", "state": "stopping", "stateReason": "user_request", "…": "…" }, "from": "running", "to": "stopping" }
```

Allowed transitions are on the [managed page](./managed.mdx#lifecycle); BYO environments accept only `archive` and `destroy`.

## Members [#members]

```bash
curl https://ai.codai.ro/v1/environments/$ENV_ID/members \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "email": "ana@example.com", "role": "developer" }'
```

`201` → `{ "member": { "user_id": "7a1c09e4-…", "role": "developer", "linux_username": "u-7a1c09e4", "port_range_start": 30100 } }`. `GET …/members` returns `{ "members": [{ "user_id", "role", "email", "name", "linux_username", "port_range_start", "ssh_public_key": true, "provisioned_at", "created_at" }] }` — `ssh_public_key` is only a flag. Errors: `404` (no account with that e-mail), `409 already_member`, `409 vm_per_dev_single_member`, `409 last_owner`.

Register your own key:

```bash
curl -X PUT https://ai.codai.ro/v1/environments/$ENV_ID/members/me/ssh-key \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "public_key": "ssh-ed25519 AAAAC3Nza… ana@laptop" }'
```

→ `{ "user_id": "…", "ssh_public_key": true }`.

### Short-lived SSH certificates [#short-lived-ssh-certificates]

Instead of a long-lived key in `authorized_keys`, ask the environment's SSH CA for a 12-hour certificate. The CA signs **`ssh-ed25519`** and **`ecdsa-sha2-nistp256`** public keys — the latter is what a non-exportable Secure Enclave key on the iPhone produces, so the codai iOS app never holds a copyable private key. Other key types (RSA, other curves) get `400`.

```bash
curl https://ai.codai.ro/v1/environments/$ENV_ID/members/me/ssh-cert \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "public_key": "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI… ana@iphone" }'
```

→ `{ "certificate": "ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA… user:<uuid>/env:<id>", "principal": "u-7a1c09e4", "valid_before": "…", "host": "203.0.113.7" }`. Save `certificate` next to the key as `<key>-cert.pub` (`codaid env ssh` does this for you). The certificate is valid from 5 minutes ago to 12 hours from now, for your Linux user only.

## Ports [#ports]

`PUT /v1/environments/{id}/ports/{port}` with `{ "visibility": "owner" | "org" | "authenticated" | "public", "label"?: string ≤ 64, "protocol"?: "http" }` returns `{ "port": { "id", "environmentId", "port", "protocol", "label", "visibility", "openedByUserId", "createdAt", "updatedAt" } }`. See [ports and previews](./ports-and-previews.mdx).

## Secrets [#secrets]

```bash
curl -X PUT https://ai.codai.ro/v1/environments/$ENV_ID/secrets/DATABASE_URL \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "value": "postgres://…" }'
```

`201` → `{ "name": "DATABASE_URL", "updated": false }` (`200` and `true` when replacing). Values are envelope-sealed at rest (≤ 64 KB) and never returned; `GET …/secrets` lists `{ "name", "created_by_user_id", "created_at", "updated_at" }`. Injecting secrets into shells inside the environment is coming soon; today only `CODAI_GIT_TOKEN` is used, for cloning (see [team](./team.mdx#shared-repository)).

## Agent tasks [#agent-tasks]

Fire-and-forget: queue a prompt, close the laptop, get a notification when the work is committed.

```bash
curl https://ai.codai.ro/v1/environments/$ENV_ID/tasks \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "prompt": "Add a /health route with a test", "branch": "feat/health" }'
```

`202` → `{ "task_id": "…", "session_id": "…", "status": "queued" }`. The relay hands the task to the daemon on its next heartbeat; the agent runs headless as **your** Linux user in your worktree, on `branch` (default `codai/task-<first 8 chars of the id>`), with autopilot permissions and a 2 h cap. When it exits, uncommitted changes are committed as `codai task <id8>` with your git identity. You then get an inbox notification (`GET /v1/notifications`) and a push on your phone: `env.task.done` with the diff stat, the short SHA and a preview URL when a dev server is listening in your port range, or `env.task.failed` with the reason (`no_agent_runtime` when the machine has no agent sidecar installed).

`session_id` is an ordinary shared session: open it in the desktop app or on the phone to watch the run live, steer it, or cancel it. `GET …/tasks/{taskId}` returns `status` (`queued` → `running` → `done` | `failed`) and `result` (`diff_stat`, `commit_sha`, `branch`, `preview_url`, `error`).

## REST exec [#rest-exec]

Run commands and touch files in a running environment over plain HTTPS, without a WebSocket client. The gateway opens a short-lived relay connection **as you** (your own API key, so the same role checks apply), sends one operation and returns the answer. Viewers get `403`; the command runs as your Linux user in your workspace.

```bash
curl https://ai.codai.ro/v1/environments/$ENV_ID/exec \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "command": "pnpm test", "timeout_ms": 120000 }'
```

`200` → `{ "ok": true, "exit_code": 0, "stdout": "…", "stderr": "", "timed_out": false, "truncated": false, "duration_ms": 8123 }`. `timeout_ms` defaults to 60 000 (max 600 000). Other relay ops go through the same endpoint with `op` + `args` (`{ "op": "fs_roots" }`, `{ "op": "job_kill", "args": { "id": 3 } }`) and return `{ ok, result }`; a refusal on the machine (path outside your roots, unknown job) is `200 { "ok": false, "error": "…" }`. Errors: `409 environment_offline` (daemon not connected), `504 host_timeout`, `502 relay_unavailable`.

- **Files**: `GET …/files?path=/home/u-…/work` lists a directory; `GET …/files/home/u-…/work/README.md` returns `{ path, content, size, truncated }` (64 KB default, `?max_bytes=` up to 512 KB); `PUT …/files/<path>` with a raw text body (≤ 2 MB, else `413`) writes it and creates parent directories.
- **Long work**: `POST …/jobs` `{ "command": "pnpm build", "expected_s": 300 }` → `201 { "job_id": 4 }`; poll `GET …/jobs/4?since=<next_offset>` for `{ job, output }` instead of holding an exec open.

## Relay status [#relay-status]

```bash
curl https://relay.codai.ro/v1/environments/$ENV_ID/status -H "Authorization: Bearer $CODAI_API_KEY"
```

```json
{
  "environment_id": "9c1e…", "online": true, "instance": "relay-7f3a", "last_heartbeat": 1790326860000,
  "activity": "agent",
  "ports": [{ "port": 3000, "pid": 4121, "process": "node" }],
  "git_state": { "branch": "main", "dirty": true, "ahead": 1, "behind": 0, "worktrees": ["/home/u-7a1c09e4/work/brivio"] },
  "daemon": { "daemon_version": "0.3.3", "os": "linux", "arch": "x86_64", "capabilities": ["shell", "pty", "tcp", "http", "lock:build", "identity:per-user"] },
  "streams": 2,
  "attached": [{ "client_id": "…", "user_id": "…", "role": "owner", "kind": "agent", "device_id": "…", "name": "Work laptop", "platform": "desktop", "attached_at": "2026-09-25T09:20:00.000Z" }],
  "locks": [{ "name": "build-0123456789ab", "holder": "u-7a1c09e4 · pnpm build", "since": "2026-09-25T09:20:30Z" }],
  "clients": 1
}
```

## Reference [#reference]

<OpenAPIPage document="./openapi/en/gateway.yaml" operations={[{path: '/v1/projects',method: 'get'},{path: '/v1/projects',method: 'post'},{path: '/v1/projects/{id}',method: 'get'},{path: '/v1/projects/{id}',method: 'patch'},{path: '/v1/projects/{id}',method: 'delete'},{path: '/v1/environments',method: 'get'},{path: '/v1/environments',method: 'post'},{path: '/v1/environments/enroll',method: 'post'},{path: '/v1/environments/{id}',method: 'get'},{path: '/v1/environments/{id}',method: 'patch'},{path: '/v1/environments/{id}/start',method: 'post'},{path: '/v1/environments/{id}/stop',method: 'post'},{path: '/v1/environments/{id}/archive',method: 'post'},{path: '/v1/environments/{id}/destroy',method: 'post'},{path: '/v1/environments/{id}/enroll-token',method: 'post'},{path: '/v1/environments/{id}/members',method: 'get'},{path: '/v1/environments/{id}/members',method: 'post'},{path: '/v1/environments/{id}/members/me/ssh-key',method: 'put'},{path: '/v1/environments/{id}/members/{userId}',method: 'patch'},{path: '/v1/environments/{id}/members/{userId}',method: 'delete'},{path: '/v1/environments/{id}/ports',method: 'get'},{path: '/v1/environments/{id}/ports/{port}',method: 'put'},{path: '/v1/environments/{id}/ports/{port}',method: 'delete'},{path: '/v1/environments/{id}/secrets',method: 'get'},{path: '/v1/environments/{id}/secrets/{name}',method: 'put'},{path: '/v1/environments/{id}/secrets/{name}',method: 'delete'},{path: '/v1/environments/{id}/tasks',method: 'get'},{path: '/v1/environments/{id}/tasks',method: 'post'},{path: '/v1/environments/{id}/tasks/{taskId}',method: 'get'},{path: '/v1/environments/{id}/exec',method: 'post'},{path: '/v1/environments/{id}/files',method: 'get'},{path: '/v1/environments/{id}/files/{path}',method: 'get'},{path: '/v1/environments/{id}/files/{path}',method: 'put'},{path: '/v1/environments/{id}/jobs',method: 'post'},{path: '/v1/environments/{id}/jobs/{jobId}',method: 'get'}]} />
