Docs · reference
REST API
Every route of the denv control plane, with auth, request and response shapes, and a curl example.
Base URL: the control plane (https://api.denv.ai for the managed service, http://localhost:8080 locally). All request and response bodies are JSON unless noted. Response field names for stored objects are the Go struct field names (ID, Name, CurrentVersionID, …); request field names are lower snake case.
Authentication
| Credential | Header | Used by |
|---|---|---|
Team API key dak_… |
Authorization: Bearer dak_… |
CLI, CI, Python adapters. Minted by the device flow or POST /v1/keys. Revoked keys are refused. |
User session dst_… |
Authorization: Bearer dst_… plus optional X-Denv-Team: <team id> |
Console. Issued by GitHub sign-in, valid 30 days. Without the header, the user's first team is used. |
| None | — | /health, /auth/*, device code and token, /denvd*. |
| Host secret / join token | inside the WebSocket hello frame |
/v1/hosts/stream (sidecars only). |
| Webhook signature | X-Hub-Signature-256 |
/webhooks/github. |
Auth columns below: key (API key or session), user (session only), none.
Errors
Every error is {"error": "<message>"} with an HTTP status. Common statuses: 400 bad input, 401 bad credential, 403 session without a team or not a member, 404 not found or not in your team, 409 conflict (duplicate name, used token), 413 body too large, 428 device authorization pending, 502 the host or an upstream refused the command, 503 feature not configured on this server, 504 sandbox did not become healthy in time.
CORS
When DENV_CONSOLE_URL is set, /v1/* and /auth/* answer CORS for that origin and for http://localhost:* / http://127.0.0.1:*, allowing Authorization, Content-Type, X-Denv-Team and GET, POST, PUT, DELETE, OPTIONS. OPTIONS returns 204.
The examples below assume:
export DENV_SERVER=http://localhost:8080 DENV_API_KEY=dak_…
Auth, teams, and keys
GET /auth/github/start — none
Query return (optional): where to send the browser after sign-in. Must be the console origin or a loopback URL; default <DENV_CONSOLE_URL>/app/. Redirects (302) to GitHub with scopes read:user user:email. 503 when GitHub sign-in is not configured.
curl -i "$DENV_SERVER/auth/github/start?return=http://localhost:3100/app/"
GET /auth/github/callback — none
GitHub's redirect target. Creates or updates the user, gives a first-time user a personal team named after their login, mints a session, and redirects to <return>#token=dst_…. 400 on unknown or expired state.
POST /auth/dev-login — none
Only when the server runs with DENV_DEV_LOGIN=1; otherwise 404. Body {email, name?}. Response {token: "dst_…"}.
curl -s -X POST $DENV_SERVER/auth/dev-login -H 'Content-Type: application/json' -d '{"email":"you@example.com"}'
POST /v1/device/code — none
Starts a CLI login. Response:
{"device_code":"ddc_…","user_code":"XXXX-XXXX","verification_uri":"<console>/app/device/","verification_uri_complete":"<console>/app/device/?code=XXXX-XXXX","expires_in":900,"interval":5}
POST /v1/device/approve — user
Body {user_code, team_id, name?}. The caller must be a member of team_id. Mints an API key named name (default cli) and marks the code approved. Response {approved: true, key_id, team_id}.
POST /v1/device/token — none
Body {device_code}. 428 {"error":"authorization_pending"} until approved; 400 {"error":"invalid_device_code"} for an unknown code; 410 if the approved key was already handed out. Response {api_key: "dak_…", team: {ID, Name, CreatedAt}}. The key is returned exactly once.
GET /v1/me — key
Response {team, teams: [{ID, Name, CreatedAt, Role}], user?}. With an API key, teams has one entry with role key; with a session, user is present and teams lists memberships.
curl -s $DENV_SERVER/v1/me -H "Authorization: Bearer $DENV_API_KEY"
GET /v1/teams — user
Response {teams: [{ID, Name, CreatedAt, Role}]}.
POST /v1/teams — user
Body {name}. Creates a team with the caller as owner. Response 201 {team}.
GET /v1/keys — key
Response {keys: [{ID, TeamID, Name, CreatedBy, CreatedAt, LastUsedAt, RevokedAt}]}. Key material is never returned.
POST /v1/keys — key
Body {name?} (default key). Response 201 {key: {…}, api_key: "dak_…"}; api_key is shown once.
curl -s -X POST $DENV_SERVER/v1/keys -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' -d '{"name":"ci"}'
DELETE /v1/keys/{id} — key
Revokes a key of the caller's team. Response {revoked: true}; 404 if not found.
Projects
GET /v1/templates — key
Query kind (optional). Response {kinds: ["rl-environment","agent"], templates: [{name, kind, description, requires?, compose, warm?}]}.
GET /v1/projects — key
Response {projects: [{ID, TeamID, Name, Kind, Source, CreatedAt}]}.
POST /v1/projects — key
Body {name, kind?, source?}. kind is rl-environment (default) or agent. Creates the project and its alpha environment. Response 201 {project, environments: [alpha]}; 409 on a duplicate name.
curl -s -X POST $DENV_SERVER/v1/projects -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"ops-world","kind":"rl-environment","source":"github:you/ops-world"}'
GET /v1/projects/{project}/envs — key
{project} is an id or a name. Response {environments: [{ID, ProjectID, Name, Stage, HostID, ParentVersionID, CurrentVersionID, Status, CreatedAt, Endpoints}]}.
POST /v1/projects/{project}/deploy — key
Body {env?, compose, warm?}. env defaults to alpha; compose is the compose file text; warm is an argv array run after health. The compose file is parsed and validated here (400 with the reason on refusal). Response 202 {deployment, phases: [...]}.
curl -s -X POST $DENV_SERVER/v1/projects/ops-world/deploy -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
-d "$(jq -n --arg c "$(cat compose.yaml)" '{env:"alpha",compose:$c}')"
GET /v1/projects/{project}/source — key
Response {source: {ProjectID, Repo, DefaultBranch, ComposePath, InstallationID, DeployBranches, LastDeployedSHA}}.
POST /v1/projects/{project}/source — key
Body {default_branch?, compose_path?, installation_id?, deploy_branches?}. Empty strings and 0 leave a field unchanged; deploy_branches is a nullable boolean. installation_id must be an installation linked to the team. Response {source}.
POST /v1/projects/{project}/deploy-from-source — key
Deploys from GitHub. Body {branch?, env?}: branch defaults to the repo's default branch; env defaults to alpha for the default branch, otherwise the branch name with every character outside [a-z0-9] replaced by -. Requires the GitHub App (503 otherwise) and a linked installation that can see the repo (409 otherwise). Response 202 {deployment, phases, branch, env}.
Environments
GET /v1/envs/{env} — key
Response {environment, connected} where connected reports whether the environment's host currently has a stream (or is a driver host).
GET /v1/envs/{env}/versions — key
Response {versions: [{ID, ProjectID, EnvID, Number, Spec, Snapshot, Healthy, CreatedAt}]}.
GET /v1/envs/{env}/deployments — key
Response {deployments: [{ID, ProjectID, EnvID, Kind, Status, VersionID, TargetEnvID, Params, Error, CreatedAt, FinishedAt}]}.
POST /v1/envs/{env}/fork — key
Body {name?, version?}. version defaults to the current version and must be a healthy snapshot. Response 202 {deployment, phases}; the new environment's id is deployment.EnvID.
curl -s -X POST $DENV_SERVER/v1/envs/env_01…/fork -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' -d '{"name":"rollout-01"}'
POST /v1/envs/{env}/snapshot — key
No body. Captures live state as a new version of the same environment. Response 202 {deployment, phases}; the version id is deployment.VersionID.
POST /v1/envs/{env}/rollback — key
Body {version}. The version must be a healthy snapshot of this environment. Response 202 {deployment, phases}.
POST /v1/envs/{env}/verify — key
Body {resume?}. Requires the environment's version to declare x-denv.agent.service and x-denv.verifier (400 otherwise). Response 202 {deployment, phases}. Read the reward from GET /v1/deployments/{dep}: the verdict phase's Evidence has reward, source, exit_code, refused, missing, agent_frozen, took_ms.
POST /v1/envs/{env}/exec — key
Body {service, cmd: [...], timeout_sec?} (default 120 s). Response 200 {exit_code, stdout, stderr}. A non-zero exit code is still 200; 502 means the host could not run it.
curl -s -X POST $DENV_SERVER/v1/envs/env_01…/exec -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
-d '{"service":"opsbox","cmd":["sh","-c","id -u"]}'
PUT /v1/envs/{env}/files — key
Query service, path (both required). Body: a tar archive (Content-Type: application/x-tar), at most 32 MiB (413 otherwise). Extracted into path inside the service. Response {ok: true, bytes}.
tar -C ./fixtures -cf - . | curl -s -X PUT "$DENV_SERVER/v1/envs/env_01…/files?service=opsbox&path=/home/ubuntu/fixtures" \
-H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/x-tar' --data-binary @-
GET /v1/envs/{env}/files — key
Query service, path. Response: a tar archive of path (application/x-tar). A missing path is 502 with the host's error.
DELETE /v1/envs/{env} — key
Starts a destroy deployment. Response 202 {deployment}.
Deployments and sandboxes
GET /v1/deployments/{dep} — key
Response {deployment, phases: [{DeploymentID, Seq, Name, Status, Evidence, Error, StartedAt, FinishedAt}]}. Evidence is a JSON object whose keys depend on the phase; see Deployments.
POST /v1/sandboxes — key
One environment for one episode, returned when healthy. Body, one of two shapes:
| Field | Type | Meaning |
|---|---|---|
from_version |
string | Fork this healthy version (whole stack, data included). |
image |
string | Or: one container from this image. |
cmd |
string[] | Command for the container (image form). |
env |
object | Environment variables (image form). |
ports |
string[] | Container ports to publish (image form). Needs egress: allow. |
volumes |
string[] | Container paths that persist and fork; default ["/work"] (image form). |
egress |
deny | allow |
Default deny. |
project |
string | Project for image sandboxes; default sandboxes, created on demand. |
name |
string | Environment name; default sbx-<n>. |
timeout_sec |
int | How long to wait for health; default 600. |
Response 201 {environment, deployment}; 502 if the deployment failed; 504 on timeout.
curl -s -X POST $DENV_SERVER/v1/sandboxes -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
-d '{"image":"python:3.12-slim","cmd":["sleep","infinity"]}'
Targets, clouds, and hosts
GET /v1/clouds — key
Response {clouds: [{ID, TeamID, Provider, Name, Config, CreatedAt}]}; token, and any config key containing secret or key, is replaced by •••.
POST /v1/clouds — key
Body {provider, name?, config?}. Providers: manual, hetzner (config token, server_type default cpx31, location default nbg1, image default ubuntu-24.04), e2b and daytona (config token; untested against the live provider). The provider validates the config first (400). Response 201 {cloud}.
POST /v1/join-tokens — key
Body {cloud_id?}. Response 201 {token: "djt_…", expires_in_sec: 3600, command}.
GET /v1/hosts — key
Response {hosts: [{ID, TeamID, CloudID, Name, Status, ProviderRef, LastSeenAt, CreatedAt, Capabilities, connected}]}. Capabilities is the sidecar's report (see Protocol) or the provider's capability row.
GET /v1/targets — key
Hosts of both kinds. Response {targets: [{…host fields, kind, provider?, connected, capabilities, table?}]} where kind is sidecar or driver:<provider> and table is the static capability row for a hosted provider. Sidecars are listed first.
POST /v1/targets/connect — key
Body {provider, token?, name?, config?} for a hosted sandbox provider (e2b, daytona). Validates the token, records the cloud, and creates its driver host in one step. Response 201 {cloud, host, info, capabilities}.
POST /v1/targets/{name}/sandboxes — key
POST /v1/sandboxes pinned to the cloud named {name}. Image form only (400 otherwise); 404 if no such cloud. Response 201 {environment, deployment, target}.
GET /v1/hosts/stream — sidecar
WebSocket endpoint for denvd. Not for API clients.
GitHub
POST /webhooks/github — webhook
Verified with X-Hub-Signature-256 against DENV_GITHUB_WEBHOOK_SECRET (401 on mismatch; 503 when the app is not configured; body limit 2 MiB). Handles installation (created, unsuspend, new_permissions_accepted, deleted), push, ping. A push to a branch deploys every project whose source is that repo: the default branch to alpha; other branches to the branch-named environment only if it already exists or the project has deploy_branches on. Response {ok, sha, branch, projects: [{project, deployment?, env?, skipped?, error?}]}.
GET /v1/github/installations — key
Response {installations: [{InstallationID, TeamID, AccountLogin, CreatedAt}], configured}.
POST /v1/github/installations/{id}/link — key
Binds an installation the app has reported to the caller's team. 404 if the installation has not been seen; 409 if already linked elsewhere. Response {installation}.
GET /v1/github/repos?installation={id} — key
Repositories the linked installation can see. Response {repos: [{full_name, default_branch, private}]}.
Releases
GET /denvd, GET /denvd-linux-amd64, GET /denvd-linux-arm64 — none
The sidecar binary from the server's releases directory (DENV_RELEASES_DIR, default bin). /denvd is amd64 (?arch=arm64 selects the other). 404 when that build is not present on the server.
Events
GET /v1/events?subject=… — key
subject is env:<id>, dep:<id>, host:<id>, or cmd:<id> (progress lines). Up to 200 events, newest first, filtered to the caller's team. Response {events: [{ID, TeamID, Subject, Kind, Body, At}]}.
GET /health — none
{"status":"ok"}.