These pages document every app-lb admin endpoint that the hws Rust crate calls, one resource per page, with the request each endpoint takes, the response it returns, the errors it answers with, and the crate method that wraps it.
HTTP API is the companion page. It covers base URLs, the managed namespace door, credentials, tiers and confinement in depth, and lists routes the crate does not call (retirement, route handoffs, disk mutations, fleet views, security rules, releases). These pages stay with the crate's surface and go one level deeper on each endpoint.
Pages
| Page | Endpoints |
|---|---|
| Identity and health | /healthz, /whoami, gate probing |
| Deployments | /deployments, scaling, VM eviction, upstream drain, rollouts, discovery status |
| Exec and shell | /deployments/:id/exec, /deployments/:id/shell |
| Jobs | build, pull, mount pull, host update, /jobs |
| Images | /images |
| Metrics and host | /metrics, /certs, /disks, /fleet/deployments |
| Namespaces and plugins | /namespaces, namespace plugins, /api/plugins, /feeds |
| Observability | /namespaces/:ns/plugins/obs/api/* |
| Secrets | /secrets |
| App-tokens | /tokens |
| Auth providers | /auth-providers |
| Workflows | /workflows |
The client
[dependencies]
hws = { version = "0.2", default-features = false }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
The default feature set is cli, which links everything heyctl needs. A library consumer turns it off and picks what it uses:
| Feature | Adds |
|---|---|
blocking |
hws::blocking::Client, the same surface without async. |
config-file |
Contexts and credentials from ~/.config/heyctl, as heyctl uses them. |
artifact |
A client for an artifact store (art serve). That is a different service with its own API, documented in Artifacts, not here. |
test-util |
A scripted Transport for testing what your program does when app-lb refuses it. |
use hws::{Client, ExecRequest};
let lb = Client::builder("https://admin.us5.heyo.work")
.token(std::env::var("APP_LB_TOKEN")?)
.build()?;
let out = lb.exec("demo", &ExecRequest::new("uname -a")).await?;
println!("{} (exit {})", out.stdout, out.exit_code);
Client::builder(server) accepts a URL or a bare host:port, which becomes http://host:port. Trailing slashes are dropped. Through the managed door, the server is https://server.heyo.computer/namespaces/{ns}/lb and the token is a heyo_api_… key.
| Builder method | Effect |
|---|---|
.token(t) |
Authorization: Bearer <t>. An app-token (applb_…) or, through the door or a federated app-lb, a Heyo key. |
.basic(user, password) |
Authorization: Basic …, the operator credential. app-lb compares the header byte for byte, so the crate always sends standard padded base64. |
.timeout(d) |
Per-request deadline. Default 30 seconds. exec computes its own. |
.insecure(true) |
Skip TLS verification, for a self-signed admin listener behind a tunnel. |
Client is cheap to clone. Client::with_transport builds one over any Transport (a stub, a recorder, a proxy); a client built that way cannot open shells.
Wire conventions
The crate follows these rules on every request. A client in another language should follow them too.
- Path segments are percent-encoded. Everything except
A–Z a–z 0–9 - _ . ~is escaped, so an id containing/or?cannot address a different route. A static upstream such asus1.internal:8080goes out asus1.internal%3A8080. - Bodies are JSON with
Content-Type: application/json. The job routes (build,pull,mounts/pull,update) get{}even when there is nothing to say. app-lb reads those bodies as optional, and a body with no content type is silently treated as absent rather than rejected, so arefsent without the header would be lost. - Query booleans are
trueorfalse. app-lb parses them strictly:?force=1is a400, not a truthy value. - Reads are typed, writes are
serde_json::Value.PUT /deployments/:idreplaces the whole spec. A client that parsed a spec into a struct and wrote it back would drop every field it did not know. The write methods take aValueand send it verbatim. To edit, read the raw spec, change it, and send it back (see Replace a deployment). - Read types are lenient. Every field defaults, and fields this build does not name land in an
extramap instead of being discarded. The crate's tests read app-lb's own response fixtures (app-lb/testdata/wire/) and fail ifextrais not empty, so a field the crate stops understanding fails a test. Client::raw()returns responses as unparsed JSON. Use it to print a response or to read the half of a read-modify-write. Each page lists theRawmethod next to the typed one where there is one.
Errors
app-lb answers a failure with {"error": "…"} from its handlers, or with plain text from the gate (401) and the framework (400 malformed JSON, 415 missing content type, 422 wrong shape, 413 too large). The crate tries the envelope, falls back to the text, and maps the status to a variant of hws::Error:
| Status | hws::Error |
Notes |
|---|---|---|
401 |
Unauthorized { presented } |
Missing, wrong, revoked or expired. app-lb does not say which. presented records what was sent (None, Basic, Token). |
403 |
Forbidden { message } |
The credential is valid but out of tier or scope. The message names what is missing. |
404 with no … or no body |
NotFound { kind, name } |
A handler saying the named thing does not exist. |
404 with any other body |
Malformed { status, body } |
A router 404, such as route not exposed through the namespace proxy at the managed door. |
409 containing no running VM |
NoRunningVm { deployment } |
exec or shell with wake: false. Retry with wake. |
409 otherwise |
Conflict { message } |
A job already running, a secret still referenced, a plugin not installed, and the rest. |
502 |
Upstream { message } |
The daemon, app-obs or a remote store failed. For exec, includes app-lb's own call timing out while the command keeps running. |
503 |
ColdStartTimeout { deployment } |
Mapped this way on every route. On exec and shell it means no VM became ready within cold_start_timeout_secs; elsewhere it means app-lb could not do the work right now (see each page). |
400, 415, 422 without an envelope |
Malformed { status, body } |
The request was rejected before a handler saw it. |
| any other status with an envelope | Api { status, message } |
Most 400s, 412, 428, 500. |
Errors raised without a response: Transport (no answer), Decode (a 2xx whose body did not parse), Shell (the WebSocket failed), Timeout (a wait_for_* helper gave up) and Invalid (bad input caught before sending, such as a blank exec command or an unnamed token).
Error::is_retryable() is true for ColdStartTimeout, Upstream and Transport only. A 409 is not retryable: the job that is running will still be running. Error::is_auth() is true for 401 and 403. Error::status() returns the HTTP status where there was one.
Waiting
Builds, pulls and spec changes return before the work is done. Two helpers poll for you:
| Helper | Polls | Done when | Default timeout |
|---|---|---|---|
client.wait_for_job(job_id) |
GET /jobs/:id, or GET /deployments/:id/jobs with .in_deployment(id) |
status is not running. A failed job is returned, not raised. |
30 minutes |
client.wait_for_ready(id) |
GET /deployments/:id |
pending == 0, healthy and non-draining VMs ≥ desired_replicas, nothing draining. Static deployments and sites return at once. |
5 minutes |
Polling starts at 100 ms and doubles up to 3 seconds for jobs and 2 seconds for pools. .poll_every(d) fixes the interval, .timeout(d) changes the deadline, and .on_progress(f) gets each poll (for jobs, only the log lines that are new since the last call). Both builders can be awaited directly.
let job = lb.start_pull("demo", None, false).await?;
let done = lb.wait_for_job(&job.id)
.in_deployment(&job.deployment)
.on_progress(|p| for line in p.new_log { println!("{line}") })
.await?;
if done.status != "succeeded" {
eprintln!("pull failed: {:?}", done.error);
}
lb.wait_for_ready("demo").await?;
Use .in_deployment with a namespace token: app-lb releases before confined access to GET /jobs/:id refuse it that route, but always allow the deployment's job list.
Endpoint index
| Method and path | hws method |
Page |
|---|---|---|
GET /healthz |
healthz |
Identity |
GET /whoami |
whoami |
Identity |
GET /deployments |
deployments, raw().deployments, raw().deployments_in |
Deployments |
POST /deployments |
create_deployment |
Deployments |
GET /deployments/:id |
deployment, deployment_exists, deployment_with_timeout, raw().spec |
Deployments |
PUT /deployments/:id |
replace_deployment |
Deployments |
DELETE /deployments/:id |
delete_deployment |
Deployments |
PATCH /deployments/:id/scaling |
patch_scaling |
Deployments |
DELETE /deployments/:id/vms/:sandbox_id |
evict_vm |
Deployments |
PUT /deployments/:id/upstreams/:upstream/drain |
cordon_upstream |
Deployments |
DELETE /deployments/:id/upstreams/:upstream/drain |
uncordon_upstream |
Deployments |
POST /deployments/:id/rollouts |
start_rollout |
Deployments |
GET /deployments/:id/rollouts/:operation |
rollout |
Deployments |
GET /deployments/:id/discovery-status |
discovery_status |
Deployments |
POST /deployments/:id/exec |
exec |
Exec and shell |
GET /deployments/:id/shell |
shell |
Exec and shell |
POST /deployments/:id/build |
start_build |
Jobs |
POST /deployments/:id/pull |
start_pull |
Jobs |
POST /deployments/:id/mounts/pull |
start_mount_pull |
Jobs |
POST /deployments/:id/update |
start_update |
Jobs |
GET /deployments/:id/jobs |
deployment_jobs |
Jobs |
GET /jobs, GET /jobs/:job_id |
jobs, job |
Jobs |
GET /images |
images |
Images |
POST /images/sweep |
sweep_images |
Images |
POST /images/:name/offload |
offload_image |
Images |
PATCH /images/:name |
pin_image |
Images |
DELETE /images/:name |
delete_image |
Images |
GET /metrics |
metrics |
Metrics |
GET /certs |
certs |
Metrics |
GET /disks |
disks |
Metrics |
GET /fleet/deployments |
raw().fleet_deployments |
Metrics |
GET /namespaces, POST /namespaces, DELETE /namespaces/:name |
namespaces, create_namespace, delete_namespace |
Namespaces |
GET /namespaces/:ns/plugins |
namespace_plugins |
Namespaces |
PUT, DELETE /namespaces/:ns/plugins/:id |
install_plugin, uninstall_plugin |
Namespaces |
GET /api/plugins, GET/PUT /api/plugins/:id, GET /api/plugins/:id/installs |
plugins, plugin, set_plugin, plugin_installs |
Namespaces |
GET /feeds, GET /feeds/:namespace |
feeds, feed_events, feed_rss |
Namespaces |
GET /namespaces/:ns/plugins/obs/api/fleet |
obs(ns).fleet |
Observability |
GET …/obs/api/deployments/:id |
obs(ns).deployment |
Observability |
GET …/obs/api/deployments/:id/logs |
obs(ns).logs |
Observability |
GET, POST …/obs/api/alerts, DELETE …/obs/api/alerts/:id |
obs(ns).alerts, create_alert, delete_alert |
Observability |
GET /secrets, POST /secrets |
secrets, secrets_in, put_secret |
Secrets |
GET, PATCH, DELETE /secrets/:id |
secret_in, secret_exists_in, patch_secret_in, delete_secret_in |
Secrets |
POST /tokens, GET /tokens |
mint_token, tokens |
App-tokens |
GET, PATCH, DELETE /tokens/:id |
token, patch_token, revoke_token |
App-tokens |
GET /auth-providers, POST /auth-providers |
auth_providers, create_auth_provider |
Auth providers |
GET, DELETE /auth-providers/:namespace/:name |
auth_provider, auth_provider_exists, delete_auth_provider |
Auth providers |
GET /workflows, POST /workflows |
workflows, create_workflow |
Workflows |
GET, PUT, DELETE /workflows/:id |
workflow, replace_workflow, delete_workflow |
Workflows |
Tiers in these pages use the names from HTTP API: Tiers: View, CRUD, and operator for routes that need an unconfined credential.