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 between brands, and your builders never work at another company's desk.
A machine is a different thing from a desk. One machine can carry several desks, and — only if that machine's owner opts in — one of those desks can belong to a different OPVS customer. Desks on the same machine are isolated from one another by the operating system: each runs as its own unix user with a private home directory, and cannot read another desk's stored credentials. See Many desks on one machine and Hosting another customer's desk.
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?pat=…&env_id=…&url=…&runtime=claude_code' | bash",
"install_command_sudo": "curl -sSL 'https://opvs.ai/install/sidecar?pat=…&env_id=…&url=…&runtime=claude_code' | sudo bash",
"poll_url": "/api/v1/workstations/a1b2c3d4-…",
"onboarding_url": "/dashboard/machines/a1b2c3d4-…",
"onboarding_steps": [
{ "key": "run_installer", "title": "Run the installer on your VPS", "state": "pending", "reason": "awaiting_callback", "hint": "This machine has never contacted OPVS…", "done": false },
{ "key": "wg_register", "title": "Join the secure mesh", "state": "blocked", "reason": "blocked_by:run_installer", "hint": null, "done": false },
{ "key": "sidecar_up", "title": "Sidecar comes online", "state": "blocked", "reason": "blocked_by:run_installer", "hint": null, "done": false },
{ "key": "ready", "title": "Desk ready", "state": "blocked", "reason": "blocked_by:run_installer", "hint": null, "done": false }
]
}
Run the installer 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.
Which of the two commands you run depends on how you log in:
| Your machine | Use | Why |
|---|---|---|
Contabo, Hetzner, DigitalOcean — you SSH in as root |
install_command |
You are already root. |
AWS EC2 (ubuntu, ec2-user) |
install_command_sudo |
AWS images disable root SSH. |
Azure (azureuser) |
install_command_sudo |
Azure images disable root SSH. |
| Anything else where you are not root | install_command_sudo |
Needs sudo. |
Both commands install the same thing from the same URL — they differ only in whether
the shell after the pipe is elevated. The installer requires root (it installs system
packages, creates a user, writes /etc, and enables systemd units), and if you run the
wrong one it refuses with exit code 6, changes nothing, and tells you to add sudo.
Do not put the variables before sudo
curl … | OPVS_PAT=… sudo bash does not work — sudo resets the environment and drops
them. You never need to: the parameters travel inside the script that the URL renders.
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. |
Reading the checklist¶
Each step reports its own outcome, so a stalled setup tells you which thing went wrong rather than only that something did.
state |
Meaning |
|---|---|
pending |
Waiting on this step. It is the one to act on. |
done |
Completed. |
failed |
This step was reached and went wrong — see reason and hint. |
blocked |
Not attempted; an earlier step hasn't completed. reason is blocked_by:<key>. |
Exactly one step in a stalled checklist carries a hint — the first non-done one.
Everything after it is blocked with no hint, so there is a single thing to act on.
reason codes: awaiting_callback · awaiting_wg_peer · awaiting_first_health_check ·
awaiting_ready · misrouted · unreachable · suspended · blocked_by:<step key>.
The three setups that go wrong most often are told apart by where the checklist stalls:
| What happened | Stalls at | reason |
|---|---|---|
| Outbound firewall blocked the installer | run_installer |
awaiting_callback |
| One-liner run on a machine that already hosts another desk | sidecar_up |
misrouted |
| Desk registered, then its sidecar stopped answering | sidecar_up |
unreachable |
done remains on every step as an alias for state == "done" and is not going away.
Add ?format=yaml or ?format=md to any GET for a compact, agent-friendly view. Both
carry state and reason; the markdown view marks a failed step [!] and a blocked one
[-].
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. |
cotenancy_allowed |
boolean | Whether this machine may host a desk belonging to another OPVS customer. false on every machine until its owner sets it. Only the owning brand can set it; see Hosting another customer's 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, image_url, and three read-only fields describing the
software the desk is actually running, as reported by the desk itself on its last health
check: sidecar_version (e.g. 0.2.10), sidecar_code_digest (a fingerprint of the installed
sidecar code — two desks on the same version can differ here, and this is the field that says
so; null on a desk whose sidecar predates 0.2.10), and last_upgrade_at (when either last
changed; null until a change has been observed). All three are null until OPVS has heard from
the desk. 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. |
Dormant desks¶
A desk we have not heard from for more than two weeks is marked dormant, and a dormant desk does not count towards your workstation cap.
This matters most for a desk that was ordered and never brought up. Health polling needs
somewhere to poll, so a desk whose installer never ran has no sidecar to answer and its
status stays provisioning indefinitely — it never becomes unreachable, because nothing
is checking. Before this rule, such a desk went on spending a slot forever and could refuse
you a machine you had paid for.
- Nothing is deleted. The desk stays on your list, in its own group, so you can see it and decide whether to release it.
- It comes back on its own. The moment its sidecar reports in, the desk is live again and counts as normal. There is nothing to undo and no ticket to raise.
- A suspended desk is never dormant. Pausing a desk stops health polling by design, so its last-seen time stops moving. A pause holds your slot for as long as you want it held — otherwise resuming could put you over your cap with no way back.
Expired-login checks skip dormant desks for the same reason: there is no machine behind them
to log in on, so the answer could only ever be "cannot say". GET
/api/v1/environments/credentials/reauth-status reports checked (desks actually read) and
dormant (desks skipped) side by side — the two always add up to your fleet.
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. |
Many desks on one machine¶
A machine you already run can carry more than one desk. Adding the second one needs no
new install command and no new token — it reuses the install key the machine already
holds, which is why the response hands you an add_box_command rather than a fresh PAT.
GET /api/v1/environments/{env_id}/slots¶
List every desk on the machine that env_id belongs to. Any desk on the machine answers
for the whole machine, so you do not need to know which row is the "first" one.
curl https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/slots \
-H "Cookie: $SESSION_COOKIE"
Response:
{
"machine_id": "a1b2c3d4-0000-4444-8888-aaaabbbbcccc",
"machine_name": "elena-desk",
"desk_count": 2,
"machine_registered": true,
"cotenancy_allowed": false,
"foreign_desk_count": 0,
"hosted_elsewhere": false,
"slots": []
}
foreign_desk_count is a count and never an identity. If another customer's desk is
running on your machine you are told that it is, and how many — never which brand, which
name, or which id. That is deliberate: naming the guest would hand the host exactly the
information co-tenancy is designed to withhold.
hosted_elsewhere is the mirror of it: true means this desk of yours is sitting on a
machine owned by somebody else.
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. |
POST /api/v1/environments/{env_id}/slots¶
Add another desk for your own brand on the same machine.
curl -X POST https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/slots \
-H "Cookie: $SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"name": "marco-desk", "runtime": "codex"}'
| Field | Type | Notes |
|---|---|---|
name |
string | Required. A human name for the new desk. |
runtime |
string | claude_code (default), codex or custom. |
workspace_path |
string | Optional. Defaults to a path derived from the desk. |
description |
string | Optional role line. |
Returns 201 with the desk record plus add_box_command and add_box_command_sudo — run
one of them on the machine, exactly as with the first desk. The pat in the response is the
machine's existing install key, reused; install_key_from_env_id names the desk it came
from. Rotating that key re-installs every desk that shares it.
Errors:
| Code | When | Resolution |
|---|---|---|
401 |
Not authenticated. | Sign in. |
404 |
No desk with that id in your brand. | Check the id against GET /api/v1/workstations. |
409 |
The machine has never contacted OPVS, or the desk sits on a machine owned by another brand. | Finish installing the machine first, or add the desk from a machine you own. |
Hosting another customer's desk¶
A machine can carry a desk belonging to a different OPVS customer. This is off everywhere until you turn it on, and it is a second door rather than a wider version of the first: a co-tenant desk mints its own install key instead of sharing yours, so rotating your machine's key does not re-install it.
Step 1 — the owner opts in¶
Consent is a field on the machine, set by the brand that owns it.
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 '{"cotenancy_allowed": true}'
In the dashboard the same switch is on the machine's Settings lens. Turning it off again stops the next placement; it does not evict a desk that is already running. Only the owning brand can set it, so a platform administrator acting without a brand session is refused.
POST /api/v1/environments/{env_id}/cotenants¶
Place a desk belonging to another brand on this machine.
curl -X POST https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/cotenants \
-H "Cookie: $SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"brand_id": 42, "name": "guest-desk", "runtime": "claude_code"}'
| Field | Type | Notes |
|---|---|---|
brand_id |
integer | Required. The brand the new desk belongs to. |
name |
string | Required. A human name for the desk. |
runtime |
string | claude_code (default), codex or custom. |
workspace_path |
string | Optional. |
description |
string | Optional. |
Response: 201 with the same shape as a slot, and a pat that is newly minted for
the guest rather than the host's key.
Errors:
| Code | When | Resolution |
|---|---|---|
401 |
Not authenticated. | Sign in. |
404 |
No machine with that id you can see. | Check the id. |
409 |
The machine has not opted in to co-tenancy. | The owning brand sets cotenancy_allowed first. |
409 |
The named brand already owns this machine. | A desk for the machine's own brand is POST /environments/{env_id}/slots, which shares the install key on purpose. |
409 |
The machine has never contacted OPVS. | Finish installing it first. |
DELETE /api/v1/environments/{env_id}/cotenants/{guest_env_id}¶
Remove a co-tenant desk and retire the key that was minted for it.
curl -X DELETE \
https://api.opvs.ai/api/v1/environments/a1b2c3d4-0000-4444-8888-aaaabbbbcccc/cotenants/9f8e7d6c-1111-4444-8888-ccccddddeeee \
-H "Cookie: $SESSION_COOKIE"
Response:
{
"deleted": true,
"token_id": "…",
"token_revoked": true,
"token_shared": false,
"next_step": "…"
}
token_revoked is the field that matters: the guest's install key is retired with the desk,
so a removed co-tenant cannot keep talking to OPVS. token_shared is true only when the
key was the machine's shared one, in which case it is left alive for the desks still using it.
Errors:
| Code | When | Resolution |
|---|---|---|
401 |
Not authenticated. | Sign in. |
404 |
No such machine, or no such guest desk on it. | Check both ids against GET /api/v1/environments/{env_id}/slots. |
409 |
The desk named is not a co-tenant of this machine. | Remove a desk of your own brand with DELETE /api/v1/workstations/{id} instead. |
Deleting a machine that still hosts a guest is refused¶
A host machine that still carries another brand's desk cannot be deleted. The refusal is a
409, and it reports how many guest desks are in the way without naming them or their
brand — the same count-not-identity rule as foreign_desk_count. Remove the co-tenants
first, then delete the machine.
The refusal exists because the database link is ON DELETE SET NULL: dropping the host row
provably cannot take its guests with it. It would leave them alive and detached — still
billing, still authenticating, still claiming to be machines in their own right, on hardware
that no longer has a record. Nothing in the product would have said so.
What is proven, and what is not¶
Isolation between desks on one machine was verified before this was released: two brands, one host, with every denied read run beside a control proving the file was really there, and each of the three isolation guards deliberately broken to confirm it would refuse to start.
Two limits are worth stating plainly rather than discovering:
- A co-tenanted desk has been provisioned, measured and torn down, but no customer build has yet been run end to end on one. A desk reporting healthy is not the same as a desk that has finished a job.
- The isolation measurement was taken on hosts with the Linux
ptracerestriction enabled, which is the default on current Ubuntu and Debian images. A machine with it switched off was measured separately and behaved the same way, but not under a full co-tenancy walk.
If either matters to you, run one desk per machine — which remains fully supported, and is still what happens unless you opt in.
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. current counts live desks only — see Dormant desks. |
404 |
No workstation with that id in your brand. |
422 |
Invalid body (e.g. blank name). |