@husk-ai/sdk
A dependency-free, typed client for the Husk control plane. It uses global
fetch, takes its shared types from @husk-ai/core, and speaks exactly the routes
in the HTTP API.
npm install @husk-ai/sdkimport { HuskClient } from '@husk-ai/sdk';
const husk = new HuskClient({ baseUrl: 'http://127.0.0.1:7377' });
const computer = await husk.computers.create({ provider: 'local' });
await husk.computers.writeFile(computer.id, '/work/hello.txt', 'hi from the sdk');
const shown = await husk.computers.exec(computer.id, { cmd: 'cat /work/hello.txt' });
console.log(shown.stdout.trim()); // hi from the sdk
await husk.computers.destroy(computer.id);Start the server first with husk serve.
The contract is tested, not asserted
An SDK and a server can drift into two different products while both look
healthy, because each one's tests mock the other. So src/contract.test.ts
boots a real @husk-ai/server on an ephemeral port, backs it with a real
ComputerManager and LocalProvider, and drives every public method against
it, asserting the status, body shape and decoded type of each.
That test exists because this client once shipped speaking a specification the
server never implemented: twelve of twenty methods returned 404, and several
more returned 200 with a body contradicting their declared type. Both sides'
unit tests were green the whole time. Only the model provider and the agent are
still doubles, because a real one needs an API key.
Configuration
new HuskClient({
baseUrl: 'http://127.0.0.1:7377', // default
token: process.env.HUSK_TOKEN, // required when the server sets HUSK_TOKEN
fetch: myFetch, // inject one; defaults to global fetch
timeoutMs: 30_000,
});Every method takes an optional trailing { signal } and honours it — including
the streaming ones, which abort the underlying request rather than merely
stopping iteration.
Health and capability
await husk.health(); // { ok, version, uptimeSec }
await husk.doctor(); // the DoctorReport that husk doctor printsdoctor() is the honest one: it reports each provider's availability, whether
it is isolated, and why not when it is not.
Computers
husk.computers.list();
husk.computers.create(spec); // ComputerSpec -> ComputerInfo
husk.computers.get(id);
husk.computers.stop(id);
husk.computers.start(id);
husk.computers.destroy(id);
husk.computers.exec(id, { cmd, cwd?, env?, timeoutSec?, stdin? });
husk.computers.execStream(id, { cmd }); // AsyncIterable<ExecEvent>
husk.computers.listDir(id, '/work');
husk.computers.readFile(id, path); // Uint8Array
husk.computers.readTextFile(id, path); // string
husk.computers.writeFile(id, path, content);
husk.computers.remove(id, path, { recursive: true });
husk.computers.exposePort(id, 8000);Streamed output carries its payload on data, not text:
for await (const event of husk.computers.execStream(id, { cmd: 'ls -la /work' })) {
if (event.type === 'stdout') process.stdout.write(event.data);
if (event.type === 'exit') console.log('exit', event.result.exitCode);
}The stream ends on the server's event: done frame, which is consumed rather
than yielded — the loop above never sees a trailing empty object.
Husks and runs
husk.husks.list();
husk.husks.get(name); // { spec, yaml }
husk.husks.create({ yaml }); // or { spec }
husk.husks.update(name, { spec });
husk.husks.validate({ yaml }); // { ok, issues? }
husk.husks.delete(name);
husk.husks.run(name, { input: 'check the build' });
husk.husks.runStream(name, { input }); // AsyncIterable<RunEvent>
husk.runs.list({ husk, limit, cursor });
husk.runs.get(runId);
husk.runs.cancel(runId);
husk.runs.delete(runId);validate answers rather than throwing, so you can wire it straight to a form:
const { ok, issues } = await husk.husks.validate({ yaml });
if (!ok) console.error(issues.join('\n'));Approvals
When a husk runs with approvalMode: 'ask' and calls a dangerous tool, the run
blocks and the stream emits approval_required:
for await (const event of husk.husks.runStream('triage', { input, approvalMode: 'ask' })) {
if (event.type === 'approval_required') {
await husk.approvals.answer(event.approvalId, { approve: false });
}
}husk.approvals.list() returns everything currently waiting. An unanswered
approval is denied when it times out after 120 seconds.
Sessions
husk.sessions.discover({ source: 'claude-code' });
husk.sessions.import({ path });
husk.sessions.distill({ transcriptId, useModel: true });
husk.sessions.distillStream({ transcriptId }); // progress eventsModels
husk.models.list();
husk.models.chat({ model: 'sonnet', messages });
husk.models.chatStream({ model, messages }); // AsyncIterable<StreamEvent>Events
const stream = husk.events({ topics: ['computers', 'runs'] });
for await (const event of stream) console.log(event.type, event.payload);
stream.close();A websocket firehose of computer state changes, run lifecycle and reaper activity — the same feed the console renders.
Errors
Every failure throws a HuskError carrying the server's own code and hint:
import { isHuskError } from '@husk-ai/sdk';
try {
await husk.computers.exec(id, { cmd: 'sudo rm -rf /' });
} catch (err) {
if (isHuskError(err)) {
console.error(err.code); // E_EXEC_DENIED
console.error(err.hint); // what to change if the refusal was wrong
}
}Codes round-trip exactly, including the server-only ones (E_HUSK_NOT_FOUND,
E_RUN_NOT_FOUND, E_APPROVAL_NOT_FOUND, E_TRANSCRIPT_NOT_FOUND,
E_ROUTE_NOT_FOUND). An unrecognised code is surfaced as itself rather than
coerced into a plausible neighbour — a mistake this client used to make, turning
every 404 into E_COMPUTER_NOT_FOUND.
Not here
One thing is deliberately absent rather than stubbed:
- A terminal client.
WS /v1/computers/:id/terminalexists, but it is one command per socket rather than a persistent shell, because there is no native pty module. Useclient.http.wsUrl(...)if you want to drive it yourself.