{ "openapi": "3.1.0", "info": { "title": "Hanzo Cloud API", "description": "Package bot is your own machines, connected and ready to take a command.", "version": "v1" }, "servers": [ { "url": "https://api.hanzo.ai" } ], "tags": [ { "name": "bot" } ], "paths": { "/v1/bot/connect": { "get": { "operationId": "get_v1_bot_connect", "summary": "The socket a bot node dials and holds open to become invokable.", "description": "Upgrades to a WebSocket and keeps it for the life of the node. cloud writes a challenge frame immediately; the node answers with a connect frame naming the protocol range it speaks, the role `node`, its own node id, and the display name, platform, agent version, capabilities and commands it reports for itself. On acceptance the session is registered, the node appears in this org's node list, and invocations begin arriving as frames on the same connection.\n\nThe upgrade needs a validated principal and answers 403 without one. The org is the gateway's verdict — injected after IAM validation and after any client copy is stripped — and is never read from the request itself, because a caller that could name an org could attach a machine into someone else's tenant.\n\nA request carrying an Origin header is refused outright. A node is a daemon and a browser has no business here; since no same-origin policy applies to WebSockets, a page could otherwise ride a signed-in viewer's session into registering a node. Removing the whole category is the gate, not an allowlist of brand domains. The handshake deadline is one fixed instant rather than a per-read timer, so a peer cannot hold a pre-handshake socket open indefinitely by sending frames this endpoint ignores.\n\nTwo things to get right. Everything the node declares about itself — capabilities, commands, platform — is a SELF-REPORT: it is useful to show and never load-bearing, because what the node may actually be asked to run is decided at this socket against the deployment's allowlist. And a node can only ever answer calls placed on its own connection: correlation ids are minted under the connection id and checked against it, so naming another node's in-flight call resolves nothing.", "tags": [ "bot" ] } }, "/v1/bot/nodes": { "get": { "operationId": "get_v1_bot_nodes", "summary": "Returns the caller org's currently connected bot nodes: what each one calls itself, the platform it runs on, its agent version, when its socket was established, and the capabilities and commands it reported.", "description": "Returns the caller org's currently connected bot nodes: what each one\ncalls itself, the platform it runs on, its agent version, when its socket was\nestablished, and the capabilities and commands it reported.\n\nOnly this org's nodes are listed — the org is half of every key in the table it\nreads — and only nodes attached to THIS replica, because the list is of live\nsockets rather than of registrations. The capability and command lists are the\nnode's own self-report: useful to show, never load-bearing, because what a node\nmay actually be asked to do is decided at the socket against the deployment's\nallowlist.", "tags": [ "bot" ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/nodesView" } } }, "description": "ok" } } } }, "/v1/bot/nodes/{id}/invoke": { "post": { "operationId": "post_v1_bot_nodes_by_id_invoke", "summary": "Ask one of your connected machines to run a command, and get its answer back.", "description": "Sends {command, params, timeoutMs, idempotencyKey} to the named node and answers with what the node returned: {ok, payload, code, message}, where payload is the node's own JSON passed through — cloud routes the call, it does not interpret the result. A reply that is not valid JSON becomes an empty payload rather than corrupting the response, which ok and code already qualify.\n\nNeither the node nor the org is a body field: the node is the path and the org is the caller's validated identity, and a field for either would be a field somebody could set to a stranger's. A validated principal is required (403 without one), and a node id that belongs to another org answers exactly like one that does not exist — not found — so this cannot be used to probe another tenant's fleet.\n\nAuthorization happened ONCE, at the socket, on the replica holding that node — the only place that knows what the node declared it can do. A node attached to a different replica is reached through the peer forward and is authorized by the same code with the same session in hand, so a local node and a forwarded one cannot get different answers. The timeout defaults to 30s and is clamped to 5 minutes, so one request can never pin a node's socket open indefinitely.\n\nsystem.run is rewritten before dispatch: its approval control fields are re-derived from the approval record and whatever the caller claimed is discarded, because a caller that could pre-approve itself is the whole thing approvals exist to prevent. No approval registry is wired today, so an invocation CLAIMING an approval is refused while an ordinary one is unaffected.\n\nThe one thing to get right: a refusal is a 403 carrying a DOMAIN body — {error, code, reason} — not the flat error envelope the rest of cloud returns, and the same body comes back whether the pre-flight sanitize refused it or the node's own gate did. Switch on `code`. The remaining failures are ordinary statuses: the node not answering in time is 504, and a node that disconnected or could not be reached is 502.", "tags": [ "bot" ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ] } }, "/v1/bot/peer/invoke": { "post": { "operationId": "post_v1_bot_peer_invoke", "summary": "Replica-to-replica forward of one invocation to the pod holding the node's socket.", "description": "A machine hop, not a caller-facing route. A node's socket lands on one replica while invocations land on any, so the replica that took the request forwards it here to the one that actually holds the node, and returns that answer as its own.\n\nIt authenticates with the shared peer token, compared in constant time, and carries no user identity at all. That is why the org arrives IN THE BODY here: the forwarding replica already derived it from a gateway-validated header, so the value is a fact being relayed rather than a claim being made. On any caller-facing route the same field would be a cross-tenant invoke primitive.\n\nIt fails closed on its own configuration: with no peer token set, or a half-wired cluster that has presence but no way to forward, it serves 503 and forwards nothing — an unauthenticated endpoint that takes an org from a body is precisely the hole. A missing or wrong token is 403, and the forwarded body is bounded on read.\n\nTwo things to get right. Its refusals are text/plain rather than the JSON every zip error uses, so a client decoding them as JSON will fail on the error path only. And an invocation that RAN but was denied still answers 200 here, carrying a stable error token in the JSON body — no such node, timeout, node gone, denied, failed — which the calling replica maps back onto the status codes a caller sees. Authorization already ran on this replica at the socket and is deliberately not repeated.", "tags": [ "bot" ] } } }, "components": { "schemas": { "nodeView": { "properties": { "caps": { "description": "Caps is the capability list the node reported. It is a self-report, useful\nto SHOW and never load-bearing: what a node may actually be asked to do is\ndecided at the socket by the deployment's allowlist.", "items": { "type": "string" }, "type": "array" }, "commands": { "description": "Commands is the command list the node reported. Same standing as Caps: a\nself-report, checked again at the socket before anything runs.", "items": { "type": "string" }, "type": "array" }, "connectedAt": { "description": "ConnectedAt is when this node's socket was established, RFC3339 UTC.", "type": "string" }, "displayName": { "description": "DisplayName is the human name the node reported for itself.", "type": "string" }, "id": { "description": "ID is the node's own identifier within the org — the value\nPOST /v1/bot/nodes/{id}/invoke addresses it by.", "type": "string" }, "platform": { "description": "Platform is the operating system and architecture the node reported.", "type": "string" }, "version": { "description": "Version is the node agent's own version string.", "type": "string" } }, "type": "object" }, "nodesView": { "properties": { "nodes": { "description": "Nodes is every node of the caller's org with a live socket to THIS replica,\nordered by id. A node connected to a different replica is not in it.", "items": { "$ref": "#/components/schemas/nodeView" }, "type": "array" } }, "type": "object" } } } }