Descanto Docs
SDKs

TypeScript SDK

@descanto/sdk: the ergonomic client for the Descanto API.

@descanto/sdk is a fetch-based, zero-runtime-dependency TypeScript client for controld's public REST API (/v1/**). ESM + CJS, works in Node >= 18, Bun, and edge runtimes with a global fetch.

Install

npm install @descanto/sdk
# or: bun add @descanto/sdk / pnpm add @descanto/sdk / yarn add @descanto/sdk

Quickstart

import { Canto } from "@descanto/sdk";

const canto = new Canto({ apiKey }); // or CANTO_API_KEY env var

const d = await canto.desktops.create({ tier: "default", billingMode: "monthly" });
await d.wake(); // waits by default (server ?wait=true + client re-poll fallback)

const { stdout } = await d.exec("whoami");
await d.writeFile("/home/user/task.txt", "hello");
const { url } = await d.stream({ claim: "view" });

await d.hibernate({ wait: false }); // returns an Operation handle for power users

Auth

new Canto({ apiKey }) takes a canto_sk_... API key or an AuthKit JWT access token, sent as Authorization: Bearer <credential>. When apiKey is omitted, the SDK falls back to process.env.CANTO_API_KEY (guarded for runtimes with no process; pass apiKey explicitly there instead).

baseUrl defaults to http://127.0.0.1:8081, controld's own local-dev default -- this will change once a hosted Descanto API launches.

Desktops

canto.desktops.create({ tier, billingMode?, imageVersion?, idleTimeoutSecs?, env?, setupScript? }) -> Promise<Desktop>
canto.desktops.list({ state? }) -> Promise<Desktop[]>
canto.desktops.get(id) -> Promise<Desktop>
canto.desktops.update(id, { idleTimeoutSecs? }) -> Promise<Desktop> // PATCH by id, no prior get() needed

env (a Record<string, string>) and setupScript (a string) bootstrap a new desktop on its first successful wake -- see Concepts: env and setup_script for the charset rule and the 100-key/64 KiB and 64 KiB caps.

A Desktop exposes camelCase properties mapped from the wire's snake_case JSON (id, orgId, tier, state, imageVersion, generation, currentGenerationId, idleTimeoutSecs, billingMode, hostId, ephemeral), plus:

desktop.wake({ wait?, expectedGeneration? }) -> Promise<Operation>
desktop.hibernate({ wait?, expectedGeneration? }) -> Promise<Operation>
desktop.destroy({ wait?, expectedGeneration? }) -> Promise<Operation>
desktop.fork({ count, ephemeral?, acknowledgeSharedState, wait? }) -> Promise<Desktop[] | Operation>
desktop.exec(command, { timeoutSecs? }) -> Promise<{ exitCode, stdout, stderr }>
desktop.exec(command, { detached: true }) -> Promise<Process>
desktop.getProcess(processId, { tailBytes? }) -> Promise<ProcessStatus>
desktop.patch({ idleTimeoutSecs? }) -> Promise<Desktop> // PATCH, refreshes this instance in place
desktop.readFile(path) -> Promise<Uint8Array>
desktop.writeFile(path, data: Uint8Array | string) -> Promise<void> // string is UTF-8 encoded
desktop.stream({ claim?, takeover? }) -> Promise<{ url, expiresUnix }>
desktop.refresh() -> Promise<Desktop> // re-fetches current state

Guest file paths (readFile/writeFile) accept either an absolute (/home/user/notes.txt) or already-relative (home/user/notes.txt) form -- a single leading / is stripped before the request is sent, matching controld's own path-parameter convention (sent without a leading slash; the server re-prepends it).

TTL: update/patch

Two equivalent ways to change a desktop's idle_timeout_secs (0 disables auto-hibernate) -- a static call by id, or an instance method that refreshes the Desktop you already have in place:

await canto.desktops.update(desktopId, { idleTimeoutSecs: 3600 });
// or, if you already hold a Desktop:
await desktop.patch({ idleTimeoutSecs: 3600 });

See Concepts: TTL and auto-hibernate.

Detached exec and the Process handle

Pass { detached: true } to spawn a command in the guest instead of blocking on it -- the call resolves as soon as the command is spawned (202 under the hood), returning a Process handle rather than { exitCode, stdout, stderr }:

const proc = await d.exec("long-running-task.sh", { detached: true });
proc.processId; // "p_..."

const status = await d.getProcess(proc.processId, { tailBytes: 65536 });
status.status; // "running" | "exited" | "lost"
status.exitCode; // only meaningful once status === "exited"
status.stdoutTail;
status.stderrTail;

getProcess re-fetches GET /v1/desktops/{id}/processes/{process_id} each call -- poll it yourself on whatever cadence fits (there's no built-in wait-style helper for processes, unlike desktop mutations). See Concepts: background processes for the tail-size cap and the "killed on hibernate" caveat.

Forking

desktop.fork(...) mirrors the REST contract directly -- count and acknowledgeSharedState are required, ephemeral optional. Like wake/hibernate/destroy, it defaults to { wait: true } and resolves Desktop[]; pass { wait: false } to get the raw Operation handle back instead:

const forks = await hibernated.fork({
  count: 3,
  acknowledgeSharedState: true, // required -- omitting it throws, same as `false`
});
forks; // Desktop[], length 3, each state: "hibernated"

await forks[0].wake();

The source desktop must already be hibernated, or the call throws CantoApiError (409). See Concepts: Forking for the full model (copy-on-write semantics, why acknowledgeSharedState has no default, and ephemeral forks).

Computer use

The classic agent loop -- look (screenshot) → act (mouse/keyboard) → look again -- against an awake desktop:

const shot = await d.screenshot(); // { data: Uint8Array (PNG), width, height }
await d.screenshot({ region: { x: 0, y: 0, width: 800, height: 600 }, scalePercent: 50 });

await d.mouse.move({ x: 100, y: 200 });
await d.mouse.click({ x: 100, y: 200 }); // every input call resolves
await d.mouse.doubleClick({ button: "left" }); //   with the final cursor {x, y}
await d.mouse.drag({ to: { x: 300, y: 300 } });
await d.mouse.scroll({ direction: "down", amount: 3 });
const cursor = await d.mouse.position(); // empty batch = cursor read

await d.keyboard.type("hello world");
await d.keyboard.press("ctrl+shift+t"); // +-joined X keysyms

await d.display.get(); // { width, height } (live)
await d.display.set({ width: 1280, height: 800 }); // 640-2560 x 480-1600

d.input(actions) is the raw batch primitive the sugar above composes -- an ordered action list costs exactly one guest round trip, no matter how many actions it carries (max 50 actions, 32 KiB total text, 10s total waits per batch). See Concepts: Computer use for the full loop and API reference: Computer use for every action type.

Notes:

  • A 413 from screenshot() means the PNG exceeded the 8 MiB transfer cap -- pass region and/or scalePercent.
  • input()/screenshot() are never auto-retried (a replayed click double-clicks); a mid-batch failure (500) means actions before the failure already executed -- re-screenshot before retrying.
  • display.set() throwing a 501 CantoApiError is the "runtime resize unsupported" signal: this desktop's image/X server can't do RandR resizes, so its resolution is effectively fixed until the golden image is upgraded. The applied size survives hibernate/wake; a cold boot reverts to 1024x768.

Port ingress

Publish a TCP service listening inside an awake desktop at a stable public URL:

const p = await d.exposePort(8080);
p.url; // https://8080-<desktopId>.canto.host
p.token; // canto_pt_... -- shown EXACTLY ONCE, never recoverable

await fetch(`${p.url}/health`, { headers: { "X-Canto-Token": p.token } });
// ...or `${p.url}/health?canto_token=${p.token}`

await d.listPorts(); // [{ id, port, public, url, status, createdAt }] -- never a token
await d.stopPort(8080); // the URL 404s forever after

Exposures are private by default: without the token the URL answers 401. Pass { public: true } to skip the token entirely and serve the port to the whole internet (the response then carries no token field) -- only the desktop's UUID in the hostname obscures it.

Because the token is hash-stored and unrecoverable, re-exposing an already exposed port is a 409 rather than an idempotent success -- rotate with stopPort then exposePort. A 501 means the deployment has no ingress domain configured. See Concepts: Port ingress for the hibernation-survival model and the dashboard's noVNC embed.

Snapshots and restore

const generations = await d.listGenerations(); // -> Generation[], newest first
await d.restore(generationId, { wait?, expectedGeneration?, idempotencyKey? }); // -> Operation

Restoring never destroys newer Generations, so you can always restore forward again. A Hibernated desktop's head moves in the registry alone (the next wake() applies the target); an Awake desktop is snapshotted first -- its current state enters History as a new Generation -- then woken at the target, so nothing live is lost. restore is not auto-retried unless you pass idempotencyKey yourself, since a blind retry of a non-keyed restore could enqueue a second operation. See Concepts: Snapshots and restore for retention buckets and the full branch model.

Operations and usage

canto.operations.get(id) -> Promise<Operation>
canto.usage.query({ startMs, endMs }) -> Promise<UsageResponse>

Webhooks

const hook = await canto.webhooks.create({
  url: "https://example.com/hooks/canto",
  events: ["desktop.woken", "desktop.hibernated"],
});
hook.secret; // canto_whsec_... -- shown EXACTLY ONCE, store it

await canto.webhooks.list(); // -> WebhookSummary[], never includes secret
await canto.webhooks.delete(hook.id);
await canto.webhooks.deliveries(hook.id, { limit: 50 }); // -> WebhookDelivery[], newest first

create/delete are deliberately not auto-retried (a retried create would mint a second, distinct endpoint; a replayed delete would turn a transient network blip into a spurious 404). list/deliveries are plain GETs, retried like any read.

Verify a delivery in your receiver with verifyWebhookSignature:

import { verifyWebhookSignature } from "@descanto/sdk";

const ok = verifyWebhookSignature({
  payload: rawBody, // Buffer | string -- the RAW request body
  header: req.headers["canto-signature"],
  secret: storedSecret,
});

It checks the Canto-Signature header (t=<unix-secs>,v1=<hex HMAC-SHA256(secret, "{t}.{raw_body}")>) with a constant-time comparison and a freshness check (toleranceSecs, default 300s) -- never throws on a malformed header, just returns false. See API reference: Webhooks for the full event-type list and delivery-log shape.

Wait vs handles

wake/hibernate/destroy default to { wait: true }: the SDK sends the mutation with the server's own ?wait=true (2s cadence, capped at 120s), then -- if the server's cap is reached while the operation is still pending/running -- falls back to client-side re-polling GET /v1/operations/{id} at pollIntervalMs (default 2000ms) up to an overall pollTimeoutSecs budget (default 300s, configurable via new Canto({ pollTimeoutSecs, pollIntervalMs })), throwing CantoTimeoutError if that budget is exhausted.

Pass { wait: false } to get the Operation handle back immediately instead -- this never throws for a failed state, an explicit opt-in to the raw handle, which you then poll yourself via canto.operations.get(id).

Error handling

Every non-2xx response throws CantoApiError, parsed from the API's RFC 9457 application/problem+json body:

try {
  await canto.desktops.get("nonexistent");
} catch (err) {
  if (err instanceof CantoApiError) {
    err.status; // 404
    err.type; // "about:blank"
    err.title; // "Not Found"
    err.detail; // "desktop not found"
    err.operationId; // set when the problem concerns a specific operation
    err.isRetryable; // true for 429/503/504
  }
}

If the server responds with a non-conformant error body (not JSON, or JSON that isn't a ProblemJson), the SDK never throws a parse error out of error handling itself: detail falls back to the raw response text.

Three error types, one for each failure mode

TypeWhen it throws
CantoApiErrorAny non-2xx HTTP response, parsed from the ProblemJson body. Includes a failed operation observed within the server's own 120s ?wait=true cap (surfaced as 409).
CantoOperationErrorA { wait: true } mutation settles failed only after the server's 120s cap, observed by the SDK's own client-side re-poll fallback instead. Carries .operation (the settled Operation, state === "failed"), .detail, and .desktopId.
CantoTimeoutErrorA { wait: true } mutation's client-side poll budget (pollTimeoutSecs) is exhausted while the operation is still pending/running -- not settled at all, distinct from both errors above.
import { CantoApiError, CantoOperationError } from "@descanto/sdk";

try {
  await d.wake({ expectedGeneration: staleGeneration });
} catch (err) {
  if (err instanceof CantoApiError) {
    // Failed fast enough for the server's own ?wait=true to see it settle.
    err.status; // 409
  } else if (err instanceof CantoOperationError) {
    // Failed only after the server's 120s cap, observed by the SDK's own
    // re-poll loop instead.
    err.operation; // the settled Operation, state === "failed"
    err.detail; // operation.error, or a fixed fallback string
    err.desktopId;
  }
}

A single catch block that only checks instanceof CantoApiError would miss the slow-path case entirely -- both are guaranteed to throw something for a { wait: true } (the default) call, so a bare try { await d.wake(); } catch { ... } is safe either way. { wait: false } never throws for a failed operation.

Retry semantics

The SDK automatically retries on a network error or a 429/503/504 response, honoring a Retry-After response header (seconds or an HTTP-date) when present -- capped at 30s -- and otherwise falling back to exponential backoff (250ms * 2^attempt, full jitter), up to maxRetries (default 3), only for requests that are safe to retry:

  • Every GET (always idempotent).
  • create/wake/hibernate/destroy, because the SDK automatically attaches an Idempotency-Key header (via crypto.randomUUID()) to each of these calls unless the caller already supplied one -- controld scopes that key per-org (create) or per-desktop (mutations), so a retried request returns the original result instead of duplicating the effect. See Concepts: Operations.

4xx responses (other than 429) are never retried -- they indicate a request that won't succeed by resending it unchanged.

On this page