Visor
Package visor is the compute you rent from Hanzo: machines, GPUs and clusters — launch one, resize it, tear it down.
Package visor is the compute you rent from Hanzo: machines, GPUs and clusters — launch one, resize it, tear it down.
| Base URL | https://api.hanzo.ai |
| Operations | 34 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1172 · visor — Compute You Rent — Draft · read the specification →
visor is the compute you rent from Hanzo: machines, GPUs and clusters — launch
one, resize it, tear it down. It is the tenant's own view of that compute on the
public API, covering both what Hanzo provisions and what the customer attached,
and it fabricates nothing: a GPU row is a real machine's accelerator, a cluster
is real node pools, and a field the source does not carry is omitted rather than
invented. It is implemented in hanzoai/cloud at apps/visor (HIP-0106).
Motivation
Renting a machine is the one operation where the price must be knowable before
the money moves, so every launch here carries a quote at the same address as the
launch. And a fleet is the one inventory a tenant needs answered in a single
question rather than four, so the board unions every source the org has — the
machines Hanzo runs for it, the boxes it dialled in, its clusters, its agent run
targets at /v1/agents/targets — under "what compute do I have, and how hot is
it?". Both are properties an addressed face can have and a scatter of provider
calls cannot.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 It owns no store
Every row visor answers with is read from something that owns it, and visor persists none of it.
Provisioned compute is the compute service's: machines, node pools, the DOKS
lifecycle. Attached compute is the per-org registry of bring-your-own clusters
(apps/fleet), whose kubeconfigs are sealed in the org's KMS (HIP-1134) under a
per-org, per-project ref — one registry with two readers, visor and model
serving (HIP-1140), and therefore neither one's store. Utilization is the shared
compute time series (apps/samples), one table in the datastore with org as
its only tenant key, bound and never interpolated.
A capability that keeps a private copy of any of the three has two answers to one question. visor keeps none.
§2 The boundary: the machine, not what runs on it
visor answers for rented hardware. Everything a customer puts on it belongs somewhere else, and the split is clean at every neighbour:
- platform (HIP-1230) is the container plane: builds, releases, environments, domains, logs. "Give me a GPU box" is visor; "run this image" is platform. Neither provisions the other's noun.
- sandboxes (HIP-1146) is the compute primitive for somebody else's code — a pod whose lifetime is a request, on capacity that already exists. visor never executes a customer's code; it hands back a machine and stops.
- bot owns
/v1/bots/runs, which is a bot doing work on a desktop, live: a session./v1/visor/compute/botsis a machine of kind bot plus its agent binding — rented hardware bootstrapped with a runtime. Two nouns share a word and do not share an address. - agents (HIP-1210) owns the agent. visor owns the binding that says a given machine hosts one, because the binding is a property of the machine.
Inside visor, /v1/visor/clusters is the fleet's answer to "what clusters do I
have", provisioned and attached together, while /v1/visor/k8s is the provider
lifecycle that creates and destroys them. Both are visor's, which is exactly why
they fold under one name rather than being called two capabilities.
§3 The addresses
visor answers under /v1/visor, the one root HIP-0139 §3 gives a capability
(manifest/apps.go:309), and every noun of the compute plane is one segment
down (apps/visor/visor.go:143-268). The six top-level roots those nouns used
to sit at — /v1/machines, /v1/gpus, /v1/clusters, /v1/k8s, /v1/fleet
and /v1/compute — were six lines of the misfiled ratchet (HIP-0139 §5.1), and
all six closed by fold (HIP-0139 §7.1), never by split: there is one owner and
§1 leaves no second store to split along. No alias was left behind, so an
address that begins anywhere else is not visor's.
/v1/visor/machines— the org's machines, one machine, its agent binding, and every binding in the fleet at/v1/visor/machines/agents./v1/visor/gpus— the accelerators on those machines, and/v1/visor/gpus/alerts./v1/visor/clusters— the cluster list, attach and detach, and node-pool create, scale and delete./v1/visor/k8s— cluster list, one cluster with its pools and worker nodes, create, delete, and the fleet-wide worker nodes at/v1/visor/k8s/nodes./v1/visor/fleet— the board, the attached workers at/v1/visor/fleet/workers, the utilization series at/v1/visor/fleet/samples, and the GPU job queue at/v1/visor/fleet/jobswith a cancel./v1/visor/compute— the launch catalog at/v1/visor/compute/regionsand/v1/visor/compute/sizes, and bot machines under/v1/visor/compute/bots.
Every route with a shape to state is typed. Five are declared with prose beside the route, and each names why it cannot be a value:
POST /v1/visor/machinesandPOST /v1/visor/compute/bots/launch— the response shape is chosen by the request:dryRunanswers 200 with a price quote, a real launch answers 201 with the created resource. A typed op declares oneOut, so typing either would have to change one of the two bodies.GET /v1/visor/compute/regionsandGET /v1/visor/compute/sizes— the body is the launch catalog exactly as the compute service states it. visor does not know that shape, and inventing one is the opposite of what a passthrough is for.POST /v1/visor/compute/bots/{id}/{action}— a verb dispatch, not a resource. Themessageaction streams the bound agent's answer back untouched: the upstream body, its content type and its status. There is noOut, and declaring one would buffer the stream.
The two launches spend real money, so their prose MUST state where the quote is; a caller reading only the document has no other place to learn it.
The address visor serves is not the address it dials. The compute service keeps
its own spelling one segment apart — /v1/machines, /v1/machines/launch,
/v1/k8s/clusters, /v1/k8s/nodes and their siblings on visor.hanzo.svc —
and those literals are the wire, so they did not move with the fold
(apps/visor/client.go). In this package a cl.call or cl.op path is
upstream's and a zip.Get(reg, …) path is ours; the two are read apart by which
one holds them, never by resemblance.
§4 Tenancy
The tenant is principal.Org, minted from the validated IAM owner claim
(HIP-0026). It is forwarded to the compute service as owner=<org>, beside the
gateway's own identity headers, which the edge has already sanitized of any
client copy. It is never read from a body: a launch always lands in the caller's
own tenant, and the ownership field a request might carry is ignored, not
honoured. Absent a validated principal the answer is 403.
A machine is addressed upstream as owner plus name, so another tenant's id is
not reachable rather than refused — the surface is not an existence oracle.
Reads are open to any validated member of the org. The two mutations on
/v1/visor/k8s/clusters, create and delete, additionally require a platform
SuperAdmin or an admin of the caller's own org (requireClusterAdmin,
HIP-0118), because provisioning spends on the house account rather than the
customer's.
§5 Money
The surface declares cloud.Metered (plugin/visor/main.go).
A launch is not metered here. It fronts the compute service's resell endpoint,
which owns the balance gate and the per-hour meter; visor forwards the tenant
and returns what came back, and a dryRun quote is that service's price
verbatim. Putting a second meter on this plane would be a second number for one
machine.
visor meters exactly one unit of its own: attaching a bring-your-own cluster,
kind byo-cluster, priced from the shared compute fee (CLOUD_COMPUTE_FEE_CENTS,
whose source is the price list, HIP-1222). The customer brings the compute and
the management plane is what is charged. The debit lands through the org's
resource meter under the provider label compute — the commerce attribution and
spend-cap scope key — and is gated before the register and metered after it, on
the same key, so the amount authorized and the amount charged cannot drift. A
deployment that prices the attach at zero is ungated exactly as it is unbilled.
Every other route is free.
§6 Events and observability
visor publishes nothing on the bus: no visor.* event reaches a customer's
webhooks. Beyond the request span every route already gets, it writes no audit
record and exports no metric of its own.
What it does emit is the tenant's own data, on request.
POST /v1/visor/fleet/samples is how an attached worker self-reports
utilization; the sample is validated against the closed source and unit
vocabularies, stamped with the caller's org, and appended to the shared series
off the request path, so a slow or absent warehouse never fails an ingest.
GET /v1/visor/fleet/samples reads that series back and GET /v1/visor/fleet
folds the latest sample onto each unit.
§7 A partial answer says it is partial
Several reads here fold two independent sources, and a fold that loses one MUST still answer with the source that replied. An outage in a provider a tenant does not use must not take away the box the tenant does own.
The fold therefore carries the failure rather than swallowing it: a degraded
list names the source that did not answer and gives a terse, log-safe reason —
never the upstream's body, which for an unknown path is a page of markup. The
field is additive and omitted when everything answered, so a healthy response is
unchanged and a consumer that wants to tell an outage from an empty estate can.
Without it, "the provider is down" and "you own nothing" are the same three
bytes on the wire.
§8 Stage
ga. The manifest row declares no stage, and absent is ga (HIP-0139 §8).
§9 Upstream
The capability derives from no upstream code: apps/visor embeds nothing and
holds one HTTP client. The compute service it fronts is hanzoai/visor,
Apache-2.0, which derives from Casbin's Casibase with that notice preserved in
its LICENSE.
That client speaks two wires and every call site says which, because they cannot
be told apart by looking. The enveloped wire answers HTTP 200 with
{status, msg, data}, so a logical failure is status: "error" at 200 and a
bare status check reads it as success. The typed wire answers the value: the
status is the outcome, 204 for a void result and 404 for a miss, with no
envelope. Converting a noun from one to the other is a wire break and lands with
its caller in the same change.
Rationale
The alternative to one face over a compute service is the console and the CLI each calling that service directly. It costs a second copy of the tenant rule in every client, and the tenant rule is the whole security model here. The alternative to folding attached compute into the same lists is a second cluster surface for bring-your-own, which answers "what clusters do I have" twice and eventually differently.
Security Considerations
The wrong implementation hands one tenant another tenant's compute. Three facts prevent it and none of them takes an input the caller controls: the owner is the validated claim, so a launch cannot be addressed elsewhere; a machine is identified upstream as owner plus name, so another tenant's id resolves to not-found rather than to a refusal that confirms it exists; and the identity headers forwarded are the gateway's own, the client's having been dropped at the edge.
The attach is the sharpest edge, because a kubeconfig is both a credential for a
cluster and an instruction to dial a host. It is validated before it is sealed,
at the one check that discovery folds and hand-pasted attaches both pass
through: an exec or auth-provider credential plugin is refused, because
honouring one runs a binary in the serving process's environment, and the
apiserver must be a routable https endpoint, because a private address is a
request to dial inside the cluster. What is stored is sealed in the org's KMS,
so the custody nodes hold ciphertext, and it is never echoed back.
The admin gate on cluster create and delete protects Hanzo's own money rather than the tenant's data, which is why it is the only mutation here that asks for more than membership.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | visor at its own prefix | 34 operations |
| CLI | — | no command reaches it yet — use HTTP or an SDK |
| SDK | VisorApi in every published client | 29 of 34 — the clients are generated at their own release |
| MCP | tool visor on https://api.hanzo.ai/v1/mcp | 34 operations, 29 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/visor/gpus, operation listGpus:
hanzo has no subcommand for this operation — the CLI serves only what cloud's live route table confirms. Use HTTP or an SDK.
import { Configuration, VisorApi } from 'hanzoai';
const api = new VisorApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.listGpus();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import VisorApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = VisorApi(client).list_gpus()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.VisorAPI.ListGpus(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, visor_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = visor_api::list_gpus(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.VisorApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new VisorApi(client).listGpus();curl https://api.hanzo.ai/v1/visor/gpus \
-H "Authorization: Bearer $HANZO_API_KEY"Tool visor, op listGpus — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "visor",
"arguments": {
"op": "listGpus",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/visor/clusters/{clusterId}/pools/{poolId}/scale | Resizes a node pool to an absolute node count and returns the pool as Visor reports it after the change. |
DELETE /v1/visor/clusters/{clusterId}/pools/{poolId} | Removes a node pool from one of the caller org's clusters. |
POST /v1/visor/clusters/{clusterId}/pools | Adds a node pool to one of the caller org's clusters and answers 201 with the created pool. |
DELETE /v1/visor/clusters/{id} | Removes a BYO cluster from the caller org's fleet. |
GET /v1/visor/clusters | Returns the caller org's clusters from both sources: the managed clusters projected from Visor's node pools, and the BYO clusters attached to the… |
POST /v1/visor/clusters | Attaches a BYO cluster to the caller's org — the kubeconfig is validated, KMS-sealed and added to the fleet — and answers 201 with the cluster as it… |
POST /v1/visor/compute/bots/{id}/{action} | Message a bot, or stop it, by naming the action in the path |
GET /v1/visor/compute/bots/{id} | Returns one of the caller org's bot machines with its agent binding. |
DELETE /v1/visor/compute/bots/{id} | Tears down both halves of a bot: it unbinds the agent (best-effort — a bot with no binding still deletes), then terminates the machine. |
POST /v1/visor/compute/bots/launch | Launch a bot machine — an agent plus the machine that runs it — or price one |
GET /v1/visor/compute/bots | Returns the caller org's bot machines — the kind=bot machines — each joined with the agent binding that says which cloud Agent it runs. |
GET /v1/visor/compute/regions | The regions a machine or GPU can be launched into |
GET /v1/visor/compute/sizes | The machine and GPU sizes that can be launched |
POST /v1/visor/fleet/jobs/{id}/cancel | Cancels a queued or running render in the caller's org. |
GET /v1/visor/fleet/jobs | Returns the caller org's gpu-jobs render queue, each row tagged with the GPU it targets (empty = the shared any-GPU lane) and the node claiming it,… |
GET /v1/visor/fleet/samples | Returns the caller org's utilization series, oldest first. |
POST /v1/visor/fleet/samples | Records a BYO worker's live GPU utilization into the SAME series the fleet board overlays. |
GET /v1/visor/fleet/workers | Returns the caller org's BYO machines — the ones that dialed in via hanzo link — with everything each host reported about itself. |
GET /v1/visor/fleet | Returns every compute unit the caller's org has, from every source, each carrying its latest utilization: agent run-targets, the BYO machines that… |
GET /v1/visor/gpus/alerts | Is an HONEST empty surface: Visor exposes no GPU alert inventory, so this returns [] rather than fabricating alerts. |
GET /v1/visor/gpus | Returns one row per physical accelerator the caller's org has, derived from its real GPU machines (the size slug says how many cards a node holds)… |
GET /v1/visor/k8s/clusters/{id} | Returns one cluster's detail: node pools + worker nodes. |
DELETE /v1/visor/k8s/clusters/{id} | Destroys a DOKS cluster by id and answers 204. |
GET /v1/visor/k8s/clusters | Lists the org's DOKS clusters (Visor, house account) folded with the org's BYO clusters — ONE fleet cluster view under the unified k8s noun. |
POST /v1/visor/k8s/clusters | Provisions a DOKS cluster for the caller's org and answers 201. |
GET /v1/visor/k8s/nodes | Returns every DOKS worker node in the org's clusters as a machine — the SAME set the fleet folds in (managedMachines), exposed directly under the k8s… |
GET /v1/visor/machines/{id}/agent | Returns the agent binding of one of the caller org's machines, or 404 when the machine runs no bot runtime. |
PUT /v1/visor/machines/{id}/agent | Binds a cloud Agent to one of the caller org's machines: the machine is recorded as running that Agent's @hanzo/bot runtime. |
DELETE /v1/visor/machines/{id}/agent | Detaches the agent runtime from one of the caller org's machines. |
GET /v1/visor/machines/{id} | Returns one of the caller org's machines by its org-scoped name. |
DELETE /v1/visor/machines/{id} | Terminates one of the caller org's machines. |
GET /v1/visor/machines/agents | Returns every agent↔machine binding in the caller's org — which machines are running which cloud Agent, with vm's own reconciled status. |
GET /v1/visor/machines | Returns every machine the caller's org has — Visor's registry, the live DigitalOcean droplets and the DOKS worker nodes (deduped into one union),… |
POST /v1/visor/machines | Launch a metered machine for your org, or price one first with dryRun |
How is this guide?