Order a Workstation
A builder employee works at a workstation — a dedicated Linux desk (a VPS) that belongs to your brand and no one else. Ordering a workstation is self-serve: you name the desk, and we hand you a one-line installer to run on your machine. The desk then joins our secure mesh and comes online.
POST /api/v1/workstations
Every workstation belongs to exactly one brand. A desk is never shared across brands — your builders never touch another company's machine, and vice versa.
Hiring a builder assigns a desk automatically
You usually don't order a desk by hand. When you hire a builder from the catalog, we assign it one dedicated workstation for you — one desk per builder, bound to your brand. The builder is also bound to the repository it builds (an OPVS-controlled repo to start; connecting your own repo comes later). From there the builder claims work from its board and builds end to end.
So the everyday path is simply: hire a builder → its desk is ready → watch it build. Ordering a workstation by hand (below) is the same primitive, exposed directly for when you want to provision a desk yourself.
The manual order below and the automatic assignment on hire create the same kind of desk — a brand-pinned Linux workstation on our secure mesh.
Order a workstation
Authenticate with your dashboard session. The brand is always taken from your session — you cannot order on another brand's behalf.
curl -X POST https://api.opvs.ai/api/v1/workstations \
-H "Cookie: $SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"name": "elena-desk"}'
Body fields
| Field | Type | Notes |
|---|---|---|
name | string | Required. A human name for the desk. |
runtime | string | Agent runtime on the desk. Defaults to claude_code. Also codex, custom. |
region | string | Optional region hint. |
description | string | Optional. |
Response
The response is the onboarding envelope. The pat and the install_command that
embeds it are shown exactly once — copy the one-liner now; it is never retrievable
again (rotate a lost one with POST /workstations/{id}/regenerate-pat).
{
"workstation_id": "a1b2c3d4-0000-4444-8888-aaaabbbbcccc",
"name": "elena-desk",
"runtime": "claude_code",
"status": "provisioning",
"ready": false,
"pat": "opvs_pat_…",
"install_command": "curl -sSL https://opvs.ai/install/sidecar | OPVS_PAT=… OPVS_ENV_ID=… OPVS_URL=… OPVS_RUNTIME=claude_code bash",
"poll_url": "/api/v1/workstations/a1b2c3d4-…",
"onboarding_url": "/dashboard/workstations/a1b2c3d4-…",
"onboarding_steps": [
{ "key": "run_installer", "title": "Run the installer on your VPS", "done": false },
{ "key": "wg_register", "title": "Join the secure mesh", "done": false },
{ "key": "sidecar_up", "title": "Sidecar comes online", "done": false },
{ "key": "ready", "title": "Desk ready", "done": false }
]
}
Run the install_command as root on your target machine. It installs the runtime and the
control sidecar, then joins the WireGuard mesh — no public ports are opened on your
VPS. About 90 seconds later the desk flips from provisioning to ready.
Watch onboarding progress
Poll the workstation to watch the onboarding_steps tick off and the status reach
ready. This endpoint never returns the PAT.
GET /api/v1/workstations/{workstation_id}
| Status | Meaning |
|---|---|
provisioning | Ordered; the desk's sidecar isn't up yet. |
ready | The desk is live and reachable — your builder can claim work. |
unreachable | The sidecar stopped responding. |
suspended | The desk was suspended. |
Add ?format=yaml or ?format=md to any GET for a compact, agent-friendly view.
Manage a desk
A desk can be edited after it is ordered: renamed, given a role line and a photo, suspended
and resumed. These fields live on the environment routes, and a desk's workstation_id is
its environment id — the same UUID addresses both.
PATCH /api/v1/environments/{workstation_id}
Update a desk. Send only the fields you want to change; anything you omit is left alone.
curl -X PATCH https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc \
-H "Cookie: $SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"name": "elena-desk", "description": "Project manager"}'
| Field | Type | Notes |
|---|---|---|
name | string | The desk's human name. |
description | string | A short role line shown under the name, e.g. Project manager. |
status | string | suspended to pause the desk, provisioned to resume it. |
sidecar_url | string | Advanced. The desk's control endpoint on the mesh; changing it repoints the desk. |
Response: the full environment record, including the fields you cannot set — short_id
(a short code such as env_3k9x2ab, so a desk has a name people can say out loud), region,
vps_provider, pending_sync_count and image_url. workspace_path is fixed at creation:
changing it on a live desk would strand its files, so it is not patchable.
Errors:
| Code | When | Resolution |
|---|---|---|
401 | Not authenticated. | Sign in. The brand is always taken from your session. |
404 | No desk with that id in your brand. | Check the id against GET /api/v1/workstations. |
422 | Invalid body, such as a status outside the four values below. | Send one of provisioned, active, unreachable, suspended. |
Suspend and resume
Suspending stops health polling, so the status holds until you change it back. Resuming sets
provisioned, not active: whether a desk is live is a health assertion, and only the poller
makes it. A resumed desk turns active once its sidecar answers, or unreachable after three
missed checks.
curl -X PATCH https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc \
-H "Cookie: $SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"status": "suspended"}'
The two surfaces use different words for the same states. PATCH takes the environment
vocabulary; GET /api/v1/workstations/{workstation_id} reports the workstation one.
Environment (PATCH value) | Workstation (GET value) | Meaning |
|---|---|---|
provisioned | provisioning | Not yet confirmed live. Polling is on. |
active | ready | The sidecar answered. Set by the poller, never by you. |
unreachable | unreachable | Three consecutive health checks missed. |
suspended | suspended | Paused by you, and not polled until you resume it. |
Give a desk a photo
A desk with a name, a role line and a face reads as a person rather than a hostname.
POST /api/v1/environments/{workstation_id}/image
Set or replace the photo. Send multipart/form-data with one file part.
curl -X POST https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/image \
-H "Cookie: $SESSION_COOKIE" \
-F "file=@elena.png"
Response:
{
"env_id": "a1b2c3d4-0000-4444-8888-aaaabbbbcccc",
"image_url": "/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/image"
}
PNG, JPG, GIF and WebP are accepted up to 5 MB. The image is centre-cropped and re-encoded to a 256x256 JPEG, so every desk renders at the same aspect ratio whatever you send. Uploading again replaces the previous photo; there is no history.
Errors:
| Code | When | Resolution |
|---|---|---|
400 | Not a PNG, JPG, GIF or WebP; an empty file; or over 5 MB. | Re-encode or shrink the file, then retry. |
401 | Not authenticated. | Sign in. The brand is always taken from your session. |
404 | No desk with that id in your brand. | Check the id against GET /api/v1/workstations. |
503 | Image storage is not configured on this deployment. | Photos are unavailable until it is. Contact support. |
GET /api/v1/environments/{workstation_id}/image
Stream the photo. This is the value the environment record returns as image_url, so use it
directly as an image source rather than constructing a bucket URL.
curl https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/image \
-H "Cookie: $SESSION_COOKIE" \
-o elena.jpg
Response: the image bytes, with Cache-Control: private, no-cache, so a replaced photo is
picked up on the next load. A desk with no photo returns null for image_url in its
record — draw your own placeholder rather than a broken image.
Errors:
| Code | When | Resolution |
|---|---|---|
404 | The desk has no photo, or its stored object is missing. | Treat as "no photo" and draw a placeholder. |
502 | Image storage is unreachable. | Retry with backoff. This is not "no photo" — do not clear the desk's image on a 502. |
503 | Image storage is not configured on this deployment. | Photos are unavailable until it is. Contact support. |
DELETE /api/v1/environments/{workstation_id}/image
Clear the photo. Removes both the record's pointer and the stored object.
curl -X DELETE https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/image \
-H "Cookie: $SESSION_COOKIE"
Response: the same shape as upload, with image_url set to null. Deleting a desk that has
no photo succeeds and is a no-op.
Errors:
| Code | When | Resolution |
|---|---|---|
401 | Not authenticated. | Sign in. The brand is always taken from your session. |
404 | No desk with that id in your brand. | Check the id against GET /api/v1/workstations. |
List and release
GET /api/v1/workstations # your brand's desks
DELETE /api/v1/workstations/{workstation_id} # release a desk
Status codes
| Code | Meaning |
|---|---|
201 | Workstation ordered; onboarding started. |
401 | Not authenticated. |
403 | Over your plan's workstation cap. The body's detail carries limit_type, current, and an upgrade_url. |
404 | No workstation with that id in your brand. |
422 | Invalid body (e.g. blank name). |