# Projects

> A project is one product or monorepo.

A project is one product or monorepo.

| Operation | Method and path |
| --- | --- |
| [List projects](#list-projects) | `GET /v1/workspaces/{workspace}/projects` |
| [Create a project](#create-a-project) | `POST /v1/workspaces/{workspace}/projects` |
| [Get a project](#get-a-project) | `GET /v1/projects/{project}` |
| [Update a project](#update-a-project) | `PATCH /v1/projects/{project}` |
| [Delete a project](#delete-a-project) | `DELETE /v1/projects/{project}` |
| [Everything about a project in one call](#everything-about-a-project-in-one-call) | `GET /v1/projects/{project}/status` |
| [Get a project's saved canvas layout](#get-a-projects-saved-canvas-layout) | `GET /v1/projects/{project}/canvas` |
| [Save a project's canvas layout](#save-a-projects-canvas-layout) | `PUT /v1/projects/{project}/canvas` |
| [Canvas graph for one environment](#canvas-graph-for-one-environment) | `GET /v1/projects/{project}/environments/{environment}/graph` |
| [Save canvas node positions](#save-canvas-node-positions) | `PATCH /v1/projects/{project}/environments/{environment}/graph/layout` |

## List projects

```http
GET /v1/workspaces/{workspace}/projects
```

- **Operation:** `listProjects`
- **Scopes:** `projects:read`
- **CLI:** `guv projects list`
- **Paginated:** `?limit=` and `?cursor=`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `workspace` * | path | string | Workspace id (`ws_…`) or slug. |
| `q` | query | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | A page of Project objects. | [ProjectList](/docs/api/objects/project-list/) |

**Errors:** `400`, `401`, `403`, `404`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/workspaces/acme/projects \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "data": [
    {
      "id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
      "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
      "slug": "primecarers",
      "name": "Primecarers",
      "description": "Care management platform",
      "region": "lon1",
      "residency": "uk-only",
      "classification": "special-category",
      "repo": {
        "provider": "github",
        "full_name": "primecarers/platform",
        "installation_id": "ghi_01jbx2m9k7q4r8t5v6w3y0z1gh",
        "default_branch": "main",
        "manifest_path": "guv.yaml"
      },
      "managed_by_manifest": true,
      "safety": {
        "grade": "A",
        "evaluated_at": "2026-10-23T12:00:00Z",
        "badge_enabled": true,
        "badge_url": "https://guv.dev/safety/k7q4r8t5v6w3y0z1"
      },
      "created_at": "2026-10-22T09:30:00Z",
      "updated_at": "2026-10-23T12:00:00Z"
    }
  ],
  "next_cursor": "string",
  "has_more": false
}
```

## Create a project

```http
POST /v1/workspaces/{workspace}/projects
```

- **Operation:** `createProject`
- **Scopes:** `projects:write`
- **Idempotency-Key:** required; supports `?dry_run=true`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `workspace` * | path | string | Workspace id (`ws_…`) or slug. |

**Request body** (`application/json`, required): [ProjectCreate](/docs/api/objects/project-create/)

| Field | Type | Description |
| --- | --- | --- |
| `name` * | string |  |
| `slug` | string |  |
| `description` | string |  |
| `region` | [Region](/docs/api/objects/region/) |  |
| `residency` | [Residency](/docs/api/objects/residency/) |  |
| `classification` | [Classification](/docs/api/objects/classification/) |  |
| `repo` | [RepoBinding](/docs/api/objects/repo-binding/) |  |
| `environments` | array of string | Environments to create. Defaults to `[production]`. Default `["production"]`. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | Dry run. Nothing was changed; this is what would happen. | [Plan](/docs/api/objects/plan/) |
| `201` Created | Created. | [Project](/docs/api/objects/project/) |

**Errors:** `400`, `401`, `402`, `403`, `404`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/workspaces/acme/projects \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "slug": "string",
  "description": "string",
  "region": "lon1",
  "residency": "uk-only",
  "classification": "standard",
  "repo": {
    "provider": "github",
    "full_name": "primecarers/platform",
    "installation_id": "string",
    "default_branch": "main",
    "manifest_path": "guv.yaml"
  },
  "environments": [
    "production"
  ]
}'
```

**Example response**

```json
{
  "id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
  "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
  "slug": "primecarers",
  "name": "Primecarers",
  "description": "Care management platform",
  "region": "lon1",
  "residency": "uk-only",
  "classification": "special-category",
  "repo": {
    "provider": "github",
    "full_name": "primecarers/platform",
    "installation_id": "ghi_01jbx2m9k7q4r8t5v6w3y0z1gh",
    "default_branch": "main",
    "manifest_path": "guv.yaml"
  },
  "managed_by_manifest": true,
  "safety": {
    "grade": "A",
    "evaluated_at": "2026-10-23T12:00:00Z",
    "badge_enabled": true,
    "badge_url": "https://guv.dev/safety/k7q4r8t5v6w3y0z1"
  },
  "created_at": "2026-10-22T09:30:00Z",
  "updated_at": "2026-10-23T12:00:00Z"
}
```

## Get a project

```http
GET /v1/projects/{project}
```

- **Operation:** `getProject`
- **Scopes:** `projects:read`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [Project](/docs/api/objects/project/) |

**Errors:** `400`, `401`, `403`, `404`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/projects/shopfront \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
  "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
  "slug": "primecarers",
  "name": "Primecarers",
  "description": "Care management platform",
  "region": "lon1",
  "residency": "uk-only",
  "classification": "special-category",
  "repo": {
    "provider": "github",
    "full_name": "primecarers/platform",
    "installation_id": "ghi_01jbx2m9k7q4r8t5v6w3y0z1gh",
    "default_branch": "main",
    "manifest_path": "guv.yaml"
  },
  "managed_by_manifest": true,
  "safety": {
    "grade": "A",
    "evaluated_at": "2026-10-23T12:00:00Z",
    "badge_enabled": true,
    "badge_url": "https://guv.dev/safety/k7q4r8t5v6w3y0z1"
  },
  "created_at": "2026-10-22T09:30:00Z",
  "updated_at": "2026-10-23T12:00:00Z"
}
```

## Update a project

```http
PATCH /v1/projects/{project}
```

- **Operation:** `updateProject`
- **Scopes:** `projects:write`
- **Idempotency-Key:** required; supports `?dry_run=true`
- **Concurrency:** send the `ETag` you read as `If-Match`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |

**Request body** (`application/json`, required): [ProjectUpdate](/docs/api/objects/project-update/)

| Field | Type | Description |
| --- | --- | --- |
| `name` | string |  |
| `slug` | string |  |
| `description` | string or null |  |
| `residency` | [Residency](/docs/api/objects/residency/) |  |
| `classification` | [Classification](/docs/api/objects/classification/) |  |
| `repo` | [RepoBinding](/docs/api/objects/repo-binding/) or any or null |  |
| `safety_badge_enabled` | boolean |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. With `dry_run=true`: a Plan (application/vnd.guv.plan+json) and nothing changes. | [Project](/docs/api/objects/project/) |

**Errors:** `400`, `401`, `403`, `404`, `409`, `412`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X PATCH https://api.guv.dev/v1/projects/shopfront \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "slug": "string",
  "description": "string",
  "residency": "uk-only",
  "classification": "standard",
  "repo": {
    "provider": "github",
    "full_name": "primecarers/platform",
    "installation_id": "string",
    "default_branch": "main",
    "manifest_path": "guv.yaml"
  },
  "safety_badge_enabled": false
}'
```

**Example response**

```json
{
  "id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
  "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
  "slug": "primecarers",
  "name": "Primecarers",
  "description": "Care management platform",
  "region": "lon1",
  "residency": "uk-only",
  "classification": "special-category",
  "repo": {
    "provider": "github",
    "full_name": "primecarers/platform",
    "installation_id": "ghi_01jbx2m9k7q4r8t5v6w3y0z1gh",
    "default_branch": "main",
    "manifest_path": "guv.yaml"
  },
  "managed_by_manifest": true,
  "safety": {
    "grade": "A",
    "evaluated_at": "2026-10-23T12:00:00Z",
    "badge_enabled": true,
    "badge_url": "https://guv.dev/safety/k7q4r8t5v6w3y0z1"
  },
  "created_at": "2026-10-22T09:30:00Z",
  "updated_at": "2026-10-23T12:00:00Z"
}
```

## Delete a project

```http
DELETE /v1/projects/{project}
```

Stops all services. Databases and volumes are soft-deleted for 7 days, then their backups follow plan retention and are crypto-shredded. Requires `confirm=<slug>`; agents always need approval.

- **Operation:** `deleteProject`
- **Scopes:** `projects:delete`
- **Approval:** Always needs a human approval with a passkey, whoever asks, including agents with full scopes.
- **Idempotency-Key:** required; supports `?dry_run=true`
- **Concurrency:** send the `ETag` you read as `If-Match`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |
| `confirm` | query | string | Typed confirmation for destructive actions. Must equal the resource name (or slug/hostname). Missing or wrong values return `confirmation_required`. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | Dry run. Nothing was changed; this is what would happen. | [Plan](/docs/api/objects/plan/) |
| `202` Accepted | Accepted. The work continues in the background; follow the operation. | [Operation](/docs/api/objects/operation/) |

**Errors:** `400`, `401`, `403`, `404`, `409`, `412`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X DELETE https://api.guv.dev/v1/projects/shopfront \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

**Example response**

```json
{
  "id": "op_01jbx2m9k7q4r8t5v6w3y0z1op",
  "kind": "deployment.create",
  "status": "running",
  "summary": "Deploying web to production",
  "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
  "project_id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
  "environment_id": "env_01jbx2m9k7q4r8t5v6w3y0z1en",
  "resource": {
    "type": "deployment",
    "id": "dep_01jbx2m9k7q4r8t5v6w3y0z1dp",
    "url": "/v1/deployments/dep_01jbx2m9k7q4r8t5v6w3y0z1dp"
  },
  "progress": {
    "current_step": "build",
    "completed_steps": 1,
    "total_steps": 6,
    "percent": 20
  },
  "steps": [
    {
      "name": "resolve",
      "title": "Reading your settings",
      "status": "succeeded",
      "attempt": 1,
      "started_at": "2026-10-23T14:02:00Z",
      "finished_at": "2026-10-23T14:02:01Z"
    },
    {
      "name": "build",
      "title": "Building your app",
      "status": "running",
      "attempt": 1,
      "started_at": "2026-10-23T14:02:01Z",
      "finished_at": null
    }
  ],
  "created_by": {
    "type": "user",
    "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
    "display": "Stewart Webb"
  },
  "created_at": "2026-10-23T14:02:00Z",
  "links": {
    "self": "/v1/operations/op_01jbx2m9k7q4r8t5v6w3y0z1op",
    "events": "/v1/operations/op_01jbx2m9k7q4r8t5v6w3y0z1op/events",
    "resource": "/v1/deployments/dep_01jbx2m9k7q4r8t5v6w3y0z1dp"
  }
}
```

## Everything about a project in one call

```http
GET /v1/projects/{project}/status
```

- **Operation:** `getProjectStatus`
- **Scopes:** `projects:read`
- **CLI:** `guv status`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [ProjectStatus](/docs/api/objects/project-status/) |

**Errors:** `400`, `401`, `403`, `404`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/projects/shopfront/status \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "project": {
    "id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
    "workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
    "slug": "primecarers",
    "name": "Primecarers",
    "description": "Care management platform",
    "region": "lon1",
    "residency": "uk-only",
    "classification": "special-category",
    "repo": {
      "provider": "github",
      "full_name": "primecarers/platform",
      "installation_id": "ghi_01jbx2m9k7q4r8t5v6w3y0z1gh",
      "default_branch": "main",
      "manifest_path": "guv.yaml"
    },
    "managed_by_manifest": true,
    "safety": {
      "grade": "A",
      "evaluated_at": "2026-10-23T12:00:00Z",
      "badge_enabled": true,
      "badge_url": "https://guv.dev/safety/k7q4r8t5v6w3y0z1"
    },
    "created_at": "2026-10-22T09:30:00Z",
    "updated_at": "2026-10-23T12:00:00Z"
  },
  "headline": "All 4 services are live. £7.42 spent of your £400 cap this month. Safety grade A.",
  "environments": [
    {
      "id": "string",
      "name": "string",
      "status": "healthy",
      "services": [
        {
          "id": "string",
          "name": "string",
          "kind": "web",
          "status": "no_deployment",
          "url": "string",
          "current_deployment_id": "string",
          "last_error": "string",
          "cost_today": {
            "amount": "7.42",
            "currency": "GBP",
            "display": "£7.42"
          }
        }
      ],
      "databases": [
        {
          "id": "string",
          "name": "string",
          "engine": "string",
          "status": "string",
          "last_backup_at": "2026-10-22T13:05:00Z"
        }
      ]
    }
  ],
  "recent_deployments": [
    {
      "id": "dep_01jbx2m9k7q4r8t5v6w3y0z1dp",
      "project_id": "prj_01jbx2m9k7q4r8t5v6w3y0z1pr",
      "environment_id": "env_01jbx2m9k7q4r8t5v6w3y0z1en",
      "service_id": "svc_01jbx2m9k7q4r8t5v6w3y0z1sv",
      "service_name": "web",
      "environment_name": "production",
      "status": "active",
      "status_text": "Live since 14:05",
      "trigger": {
        "kind": "git_push",
        "actor": {
          "type": "github",
          "id": "stewartwebb",
          "display": "stewartwebb pushed to main"
        }
      },
      "source": {
        "kind": "git",
        "commit": {
          "sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
          "branch": "main",
          "message": "Fix rota export",
          "author": "Stewart Webb"
        }
      },
      "build_id": "bld_01jbx2m9k7q4r8t5v6w3y0z1bd",
      "image": {
        "digest": "sha256:4b1f0e9d8c7b6a5948372615f4e3d2c1b0a9f8e7d6c5b4a3928170f6e5d4c3b2",
        "signed": true
      },
      "rollback_of": null,
      "operation_id": "op_01jbx2m9k7q4r8t5v6w3y0z1op",
      "url": "https://www.primecarers.co.uk",
      "failure": null,
      "created_at": "2026-10-23T14:02:00Z",
      "started_at": "2026-10-23T14:02:01Z",
      "active_at": "2026-10-23T14:05:12Z",
      "finished_at": "2026-10-23T14:05:12Z",
      "duration_ms": 191000
    }
  ],
  "spend": {
    "month_to_date": {
      "amount": "7.42",
      "currency": "GBP",
      "display": "£7.42"
    },
    "forecast_month_end": {
      "amount": "7.42",
      "currency": "GBP",
      "display": "£7.42"
    },
    "cap": {
      "amount": "7.42",
      "currency": "GBP",
      "display": "£7.42"
    }
  },
  "safety_grade": "A",
  "pending_approvals": 1,
  "open_incidents": 1
}
```

## Get a project's saved canvas layout

```http
GET /v1/projects/{project}/canvas
```

- **Operation:** `getProjectCanvas`
- **Scopes:** `projects:read`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |
| `environment` | query | string | Environment name or id; an environment override is returned when present, otherwise the project layout. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [ProjectCanvas](/docs/api/objects/project-canvas/) |

**Errors:** `400`, `401`, `403`, `404`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/projects/shopfront/canvas \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "project_id": "string",
  "environment_id": "string",
  "scope": "project",
  "canvas": {
    "layout": "manual",
    "direction": "LR",
    "nodes": {
      "services.web": {
        "x": 0,
        "y": 0
      }
    },
    "viewport": {
      "x": 0,
      "y": 0,
      "zoom": 1
    }
  },
  "etag": "\"1\"",
  "managed_by_manifest": false,
  "drift": {
    "detected": false,
    "reason": "none",
    "manifest_canvas": {
      "layout": "manual",
      "direction": "LR",
      "nodes": {
        "services.web": {
          "x": 0,
          "y": 0
        }
      },
      "viewport": {
        "x": 0,
        "y": 0,
        "zoom": 1
      }
    }
  },
  "warnings": [
    {
      "path": "/canvas/nodes/services.web",
      "code": "canvas_node_unknown",
      "message": "Canvas node is not declared.",
      "fix": "Remove this canvas node or declare the resource."
    }
  ],
  "source": "api",
  "updated_at": "2026-10-22T13:05:00Z",
  "updated_by": {
    "type": "agent",
    "id": "tok_01jbx2m9k7q4r8t5v6w3y0z1tk",
    "display": "Stewart Webb via agent claude-code-on-stewart-laptop",
    "via_agent": {
      "id": "tok_01jbx2m9k7q4r8t5v6w3y0z1tk",
      "name": "claude-code-on-stewart-laptop"
    },
    "on_behalf_of": {
      "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
      "display": "Stewart Webb"
    }
  }
}
```

## Save a project's canvas layout

```http
PUT /v1/projects/{project}/canvas
```

- **Operation:** `updateProjectCanvas`
- **Scopes:** `projects:write`
- **Idempotency-Key:** required; supports `?dry_run=true`
- **Concurrency:** send the `ETag` you read as `If-Match`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |
| `environment` | query | string | Environment name or id for an environment-specific override. |

**Request body** (`application/json`, required): [CanvasLayout](/docs/api/objects/canvas-layout/)

| Field | Type | Description |
| --- | --- | --- |
| `layout` | `auto`, `manual` | Default `auto`. |
| `direction` | `LR`, `TB` | Default `LR`. |
| `nodes` | map of string to [CanvasNodePosition](/docs/api/objects/canvas-node-position/) |  |
| `groups` | array of [CanvasGroup](/docs/api/objects/canvas-group/) |  |
| `viewport` | [CanvasViewport](/docs/api/objects/canvas-viewport/) |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | Updated layout, or a Plan when dry_run=true. | [ProjectCanvas](/docs/api/objects/project-canvas/) |

**Errors:** `400`, `401`, `403`, `404`, `409`, `412`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X PUT https://api.guv.dev/v1/projects/shopfront/canvas \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "layout": "manual",
  "direction": "LR",
  "nodes": {
    "services.web": {
      "x": 0,
      "y": 0
    }
  },
  "viewport": {
    "x": 0,
    "y": 0,
    "zoom": 1
  }
}'
```

**Example response**

```json
{
  "project_id": "string",
  "environment_id": "string",
  "scope": "project",
  "canvas": {
    "layout": "manual",
    "direction": "LR",
    "nodes": {
      "services.web": {
        "x": 0,
        "y": 0
      }
    },
    "viewport": {
      "x": 0,
      "y": 0,
      "zoom": 1
    }
  },
  "etag": "\"1\"",
  "managed_by_manifest": false,
  "drift": {
    "detected": false,
    "reason": "none",
    "manifest_canvas": {
      "layout": "manual",
      "direction": "LR",
      "nodes": {
        "services.web": {
          "x": 0,
          "y": 0
        }
      },
      "viewport": {
        "x": 0,
        "y": 0,
        "zoom": 1
      }
    }
  },
  "warnings": [
    {
      "path": "/canvas/nodes/services.web",
      "code": "canvas_node_unknown",
      "message": "Canvas node is not declared.",
      "fix": "Remove this canvas node or declare the resource."
    }
  ],
  "source": "api",
  "updated_at": "2026-10-22T13:05:00Z",
  "updated_by": {
    "type": "agent",
    "id": "tok_01jbx2m9k7q4r8t5v6w3y0z1tk",
    "display": "Stewart Webb via agent claude-code-on-stewart-laptop",
    "via_agent": {
      "id": "tok_01jbx2m9k7q4r8t5v6w3y0z1tk",
      "name": "claude-code-on-stewart-laptop"
    },
    "on_behalf_of": {
      "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
      "display": "Stewart Webb"
    }
  }
}
```

## Canvas graph for one environment

```http
GET /v1/projects/{project}/environments/{environment}/graph
```

- **Operation:** `getProjectGraph`
- **Scopes:** `projects:read`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |
| `environment` * | path | string | Environment id (`env_…`) or name. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [ProjectGraph](/docs/api/objects/project-graph/) |

**Errors:** `400`, `401`, `403`, `404`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/projects/shopfront/environments/production/graph \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "environment_id": "string",
  "nodes": [
    {
      "id": "string",
      "type": "service",
      "name": "string",
      "status": "string",
      "position": {
        "x": 0.5,
        "y": 0.5
      },
      "data": {}
    }
  ],
  "edges": [
    {
      "id": "string",
      "from": "string",
      "to": "string",
      "kind": "reference",
      "observed": false,
      "label": "string"
    }
  ],
  "canvas": {
    "layout": "manual",
    "direction": "LR",
    "nodes": {
      "services.web": {
        "x": 0,
        "y": 0
      }
    },
    "viewport": {
      "x": 0,
      "y": 0,
      "zoom": 1
    }
  }
}
```

## Save canvas node positions

```http
PATCH /v1/projects/{project}/environments/{environment}/graph/layout
```

- **Operation:** `updateProjectGraphLayout`
- **Scopes:** `projects:read`
- **Idempotency-Key:** required
- **Concurrency:** send the `ETag` you read as `If-Match`

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `project` * | path | string | Project id (`prj_…`) or slug (resolved in the `Guv-Workspace` workspace, or the caller's default). |
| `environment` * | path | string | Environment id (`env_…`) or name. |

**Request body** (`application/json`, required): [GraphLayoutUpdate](/docs/api/objects/graph-layout-update/)

| Field | Type | Description |
| --- | --- | --- |
| `positions` * | map of string to object | Node id to canvas position. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `204` No Content | Done. No body. | none |

**Errors:** `400`, `401`, `403`, `404`, `409`, `412`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X PATCH https://api.guv.dev/v1/projects/shopfront/environments/production/graph/layout \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "positions": {
    "key": {
      "x": 0.5,
      "y": 0.5
    }
  }
}'
```
