Workspace providers¶
A workspace provider is an ordinary MCP server, over stdio or streamable http, that the control plane uses to start and stop the machines hosts run on. This page is the contract it follows, version 1. The control plane recognises a provider by its tool names and the version its list reports; it needs no MCP extension.
The Mac adds every kind in Settings ▸ Control plane ▸ Providers. The web page adds the API
presets (digitalocean-api, codespaces-api, coder-api, aws-ec2-api,
gcp-compute-api) in Settings ▸ Workspace providers, because a person in a browser has no
shell where the control plane runs to sign a CLI in (#1808).
From a repository (#1809). providers/create takes repository, an https address. A
provider whose New Workspace form has a repository property makes the workspace from it,
given as owner/name (Codespaces). Any other gets the same request with no repository, and the
host clones it once it joins, before clones. "Start a Machine from a Repository…" on all
three clients sends it.
Tools¶
| Tool | Required | Takes | Answers |
|---|---|---|---|
workspaces_list |
yes | nothing | {contract, workspaces: [Workspace]} |
workspace_start |
yes | {id} |
{workspace: Workspace} once it is running |
workspace_stop |
yes | {id} |
{workspace: Workspace} once it is stopped |
workspace_options |
no | nothing | {schema}: the choices for a new workspace, as JSON Schema |
workspace_create |
no | {name, options, firstBoot?} |
{workspace: Workspace, firstBootRuns} |
workspace_delete |
no | {id} |
{} once it is gone |
Making and deleting¶
workspace_optionsanswers a flat JSON Schema object: string, integer, boolean andenumproperties, each with atitle. The New Workspace form is drawn from it. Anenumproperty may havex-costs, its values' costs, such as{"s-1vcpu-1gb": "$0.009/hour"}, which the confirmation shows.workspace_createtakes the name, the chosenoptions, andfirstBoot: one shell line that installs Agents and joins it as a host, with a host code that works once. It ends with the new host's name as its second argument. Run it as the workspace's own user on its first boot, never with sudo. AnswerfirstBootRuns: trueif you will. Otherwise the person adds it over itsssh, as Add as Host… does.workspace_deletedeletes the workspace and everything on it. The control plane then removes its host, as Remove does.
Agents asks the person before it makes or deletes anything. The first-boot line is never logged.
A workspace¶
| Field | Type | Meaning |
|---|---|---|
id |
string | Stable across restarts, and unique in the list: a second workspace with the same id is left out. What workspace_start and workspace_stop take. |
name |
string | Shown on the Providers page. |
state |
string | running, starting, stopping, stopped or unknown. Anything else is read as unknown. |
ssh |
string, optional | How the control plane's machine reaches it: user@host, an alias in its ssh config, or user@host -p 2222. It may change between starts, as a VM's address can: the tunnel and the ssh start use the one listed now, and check the host key trusted at the install (#1691). |
tunnel |
boolean, optional | It can't reach the control plane, so the control plane holds a reverse tunnel over ssh. |
hostKeyRenews |
boolean, optional | Its ssh host key is made again at each start, as a Google Cloud workstation's is. After a Start that the control plane made, its ssh start takes the key it is offered and pins it again; at any other time a changed key is refused (#1701). |
cost |
string, optional | Shown as it is, such as $0.03/hour while running. At most 120 characters. |
Answers and failures¶
- Answers. A tool answers in
structuredContent. A server that can't may put the same JSON in its firsttextcontent. - Failures. A failure is a result with
isError: true. Its text is shown to the person as it is, in the provider's words, cut at 1 KB, so write a sentence. - Repeats. A start of a running workspace succeeds at once, and so does a stop of a stopped one.
How the control plane uses it¶
- Checks. When a provider is added, its
tools/listmust have the three required tools. Its firstworkspaces_listmust reportcontract: 1. If either fails, the provider is refused, naming what is missing. - Lists. The control plane lists each provider when it starts, every five minutes, after every start and stop, and when the Providers page opens.
- Time limits. A list gives up after 30 seconds. A start or stop gives up after ten minutes, and the person is told so.
- One at a time. Starts and stops go one at a time per workspace. A second Start while one is under way joins it.
- Connecting. After a successful start, the control plane waits for the host's agentsd
to connect. After two minutes it starts agentsd over the workspace's
ssh, without installing anything. After ten minutes it gives up. - Linking what it made. A workspace made with
firstBootRunsis linked to its host when a host of its name joins, within ten minutes. - Where it runs. Providers run on the control plane, never on a host, so they can start a host that is down. A control plane with several copies doesn't run them yet.
Secrets¶
- Where they live. A provider's secrets are kept in
provider-secrets.jsonin the control plane's own folder, readable by its user only. They are never in the store, never sent to a host or a client, and never logged. - How the provider gets them. A server the control plane runs gets them as environment
variables. A server at an address gets them through
${NAME}in its configured headers.
Tool maps¶
A server whose tools are named otherwise can be used through a tool map: which of its tools stands for each of the above, how the arguments are written, and where each field is in its answer. See Add a workspace provider.
An agent can write the map, or a command provider's config, at Add Provider
(Let an agent write it). The
map is then the same as a map that you wrote. The provider's record says which runtime
wrote it, and when (writtenBy), until you change it.
The command provider¶
The command provider, workspace-commands, ships with the control plane. It is a provider
that runs command lines you give it, or a preset's, through the shell. It follows the
contract on this page and speaks stdio. Its whole configuration is the JSON in
WORKSPACE_COMMANDS_CONFIG. Its secrets are the other environment variables the control
plane passes it, which its commands can use as $NAME.
WORKSPACE_COMMANDS_CONFIG='{"preset":"docker","containers":["devbox"],"ssh":"dev@127.0.0.1 -p 2222"}' workspace-commands
Workspaces, written out¶
{
"workspaces": [
{
"id": "devstack-1",
"name": "devstack",
"start": "cws start devstack-1",
"stop": "cws stop devstack-1",
"status": "cws status devstack-1",
"ssh": "devstack-1.cws",
"tunnel": false
}
]
}
iddefaults toname.${…}with no value of its own (${TOKEN}) is left for the shell, so a secret in the environment can be used either way.statusis optional. Without it, the state is the last start's or stop's result while the server runs, andunknownbefore that.
Many workspaces from one entry¶
{
"list": "doctl compute droplet list --tag-name agents --format ID,Name,Status,PublicIPv4 --no-header",
"columns": ["id", "name", "state", "ip"],
"start": "doctl compute droplet-action power-on ${id} --wait",
"stop": "doctl compute droplet-action power-off ${id} --wait",
"ssh": "root@${ip}",
"states": { "active": "running", "off": "stopped", "new": "starting" },
"create": "…", "delete": "…"
}
listprints one workspace per line, split on whitespace intocolumns. The last column takes the rest of the line.- Or, instead of
columns,"json": {"items": "<path to the array>", "id": "<path>", "name": "<path>", "state": "<path>", …}reads the output as JSON. The paths are those of a tool map, within each item. An emptyitemsmeans the output is the array. ${column}in a template is the value from the workspace's row. It is shell-quoted before the command runs.create,deleteandoptionsneedlist, which finds whatcreatemade by its name.createfills in${name},${firstBoot}and each option's key; with${firstBoot}it answersfirstBootRuns: true.deletefills in${id}and the workspace's columns.optionsis the New Workspace form's JSON Schema; a property'sdefaultfills in a choice left out. Of the presets,dockerwith animageanddigitalocean-apihave them.statesmaps the CLI's words to contract states. A status output is matched case-insensitively after trimming whitespace.- A path may pick an item of a list by a key's value:
networks.v4[type=public].ip_address.[]takes the rest of the path in each item and joins the lists it finds:reservationSet[].instancesSet.
A web request in place of a command (#1119)¶
list, start, stop, create and delete may each be a request in place of a command:
{
"list": { "url": "https://cws.example/v1/machines", "headers": { "X-Api-Key": "${CWS_KEY}" } },
"json": { "items": "machines", "id": "id", "state": "status" },
"start": { "method": "POST", "url": "https://cws.example/v1/machines/${id}/start",
"headers": { "X-Api-Key": "${CWS_KEY}" } },
"stop": { "method": "POST", "url": "https://cws.example/v1/machines/${id}/stop",
"headers": { "X-Api-Key": "${CWS_KEY}" }, "body": { "force": false } }
}
methoddefaults toGET.urlmust behttps://(orhttp://).${…}inurland inbody's text takes the workspace's values. Each value inurlis escaped as one path segment or query value.- Only
headerstake a secret:${NAME}that is not a value is the provider's secret of that name. A secret is never put in an address, a log or an error. A secret that is not given fails the call with "It needs the NAME secret." - A list request's answer is read as JSON: it needs
json, notcolumns. With"answer": "xml"it is read as XML, into the same values: an element with children is an object, a list (children all<item>, or a name ending inSet) an array, and anything else its text. A refusal's words come fromErrors.Error.Message, as AWS gives them. "awsSign": {"service": "ec2", "region": "us-east-1"}signs the request with AWS's Signature Version 4 from the secretsAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY(andAWS_SESSION_TOKENwhen given). The secret key is never sent."googleAuth": {"scope": "https://www.googleapis.com/auth/compute"}sends the request with an OAuth access token for the scope, which the service account's JSON key in the secretGOOGLE_SERVICE_ACCOUNT_KEYgets from itstoken_uriwith a signed JWT (RFC 7523). The token is kept until a minute before it ends. The key is never sent, logged or said.- An answer other than 2xx is a failure, in the provider's own words (its JSON's
message), cut at 1 KB. A redirect is not followed, so a header never goes to another address. - An agent's draft with a request, or with a preset's
apiorurl, is asked about before its check runs (#858), as the request could send a secret anywhere.
Presets¶
{"preset": "<name>", …parameters}. A preset is a set of the commands above, compiled in.
Its parameters fill them in.
| Preset | Parameters | List / start / stop | ssh |
|---|---|---|---|
docker |
containers (names, optional), ssh, image (optional) |
With containers: docker inspect -f '{{.State.Status}}' ${id} / docker start ${id} / docker stop ${id}. Without: docker ps -a --filter label=agents.workspace --format '{{.Names}}\t{{.State}}\t{{.Label "agents.ssh"}}', columns id state ssh, with the same start and stop. A container opts in with docker run --label agents.workspace --label agents.ssh='…'. With image and no containers: create docker run -d --name ${name} --label agents.workspace ${image} && docker exec ${name} sh -c ${firstBoot}, delete docker rm -f -v ${id}, options image (default the given one). States: running→running, exited/created→stopped, restarting→starting. |
each container's agents.ssh label; else ssh as given |
doctl |
tag |
doctl compute droplet list --tag-name ${tag} … / droplet-action power-on --wait / power-off --wait. States: active/new/off. |
root@${ip} or user param |
codespaces |
repo (optional) |
gh codespace list --json name,displayName,state / gh codespace ssh -c ${id} -- true (a codespace starts when used) / gh codespace stop -c ${id}. States: Available→running, Shutdown→stopped, Starting→starting. |
gh codespace ssh --config alias, tunnel: true |
coder |
none | coder list -o json / coder start -y ${id} / coder stop -y ${id}. Read as JSON (latest_build.status). |
coder.${name} (from coder config-ssh) |
gcloud-workstations |
cluster, config, region, project (optional: gcloud's default), ssh |
gcloud workstations list … --format='value(name.basename(),state)' / start / stop. States: RUNNING (STATE_RUNNING in older gcloud) and so on; RECONCILING→starting. A workstation's ssh host key is new at each start: the ssh start after a Start takes the new one (#1701). |
ssh as given (an ssh config host that reaches gcloud workstations start-tcp-tunnel), tunnel: true, hostKeyRenews: true |
digitalocean-api |
tag, user (default root), sshKeys (optional, key ids or fingerprints), tunnel, api (optional) |
Secret DIGITALOCEAN_API_TOKEN. GET /v2/droplets?per_page=200&tag_name=${tag} / POST /v2/droplets/${id}/actions {"type":"power_on"} / power_off. Create POST /v2/droplets with the tag and user_data that makes the user agents and runs ${firstBoot} as it, with linger; options region (nyc3), size (s-1vcpu-1gb), image (ubuntu-24-04-x64). Delete DELETE /v2/droplets/${id}. States as doctl. |
${user}@ the public IPv4 |
codespaces-api |
repo (optional, owner/name), ssh (optional), api (optional) |
Secret GITHUB_TOKEN. GET /user/codespaces (or /repos/${repo}/codespaces) / POST /user/codespaces/${id}/start / …/stop. States as codespaces, and Queued, Awaiting, Rebuilding→starting. Makes a codespace from a repository (#1809): POST /repos/${repository.owner}/${repository.name}/codespaces with the name as display_name, the form's one option repository (owner/name); delete is DELETE /user/codespaces/${id}. It has no user data, so firstBootRuns is false: Add as Host… installs Agents over ssh. |
cs.${id}.${branch} (gh's --config host, ProxyCommand gh) or ssh; tunnel: true |
aws-ec2-api |
region (such as us-east-1), tag (a tag key, default agents), user (default ec2-user), tunnel, api (optional) |
Secrets AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN for a role's keys); each request is signed with SigV4. EC2's Query API, answered in XML: DescribeInstances with the tag and every state but terminated, read at reservationSet[].instancesSet / StartInstances / StopInstances with InstanceId.1=${id}. Name from the Name tag. States: pending→starting, running, stopping and shutting-down→stopping, stopped. No create or delete. |
${user}@ the public IPv4 |
gcp-compute-api |
project, zone (such as us-west1-b), label (a label key, default agents), user (default agents), sshKeys (optional, public key lines), tunnel, api (optional) |
Secret GOOGLE_SERVICE_ACCOUNT_KEY (googleAuth). GET /compute/v1/projects/${project}/zones/${zone}/instances?filter=labels.${label}:* / POST …/instances/${id}/start / …/stop. Create POST …/instances with the label, a boot disk that is deleted with the VM, an external address on the default network, the keys in the metadata agents-ssh-keys, and a startup script that runs once: it makes the user agents with those keys (not Google's ssh-keys, whose user is a sudoer), turns on linger and runs ${firstBoot} as it; options machineType (e2-small), image (projects/debian-cloud/global/images/family/debian-12), diskSizeGb (20). Delete DELETE …/instances/${id}. States: PROVISIONING and STAGING→starting, RUNNING, STOPPING and SUSPENDING→stopping, TERMINATED, STOPPED and SUSPENDED→stopped. |
${user}@ the external IPv4 |
coder-api |
url (the deployment) |
Secret CODER_SESSION_TOKEN. GET /api/v2/workspaces?q=owner:me / POST /api/v2/workspaces/${id}/builds {"transition":"start"} / stop. States as coder. |
coder.${name} (from coder config-ssh, ProxyCommand coder) |
A CLI preset needs its CLI installed and signed in where the control plane runs. If the CLI
isn't there, its first command exits 127, and the error is "‹cli› isn't installed where the
control plane runs". An API preset (-api) needs only its token, as the provider's secret.
api sends it somewhere other than the provider's own address (a proxy); the API presets'
tests point it at a fake server.
Behaviour¶
- Commands run through
/bin/sh -c, from the server's working directory (the control home). - Timeouts:
statusandlist25 s;start,stop,createanddelete9 min 55 s. - A non-zero exit is a failure. The text is the last 20 lines of stderr (or stdout), cut at 1 KB.
workspace_startrunsstart, thenstatusif there is one, until it says running, every 5 s within the timeout.workspace_stopdoes the same for stopped.- No command runs as root, and nothing asks for sudo. A preset never adds
sudo. - It writes nothing to disk and logs to stderr. Commands are logged, but the environment is
not, and neither are
${…}values that come from secrets. workspace-commands --versionprints its version.