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/sdkQuickstart
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 usersAuth
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() neededenv (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 stateGuest 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-1600d.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
413fromscreenshot()means the PNG exceeded the 8 MiB transfer cap -- passregionand/orscalePercent. 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 a501CantoApiErroris 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 to1024x768.
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 afterExposures 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? }); // -> OperationRestoring 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 firstcreate/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
| Type | When it throws |
|---|---|
CantoApiError | Any 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). |
CantoOperationError | A { 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. |
CantoTimeoutError | A { 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 anIdempotency-Keyheader (viacrypto.randomUUID()) to each of these calls unless the caller already supplied one --controldscopes 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.