husk serve exposes a JSON control plane on 127.0.0.1:7377. Everything below was
read out of packages/server/src/routes/ and checked against a running server. Where
this page and docs/API.md disagree, the code wins and the difference is called out.
husk serve
curl -s localhost:7377/health{ "ok": true, "version": "0.1.0", "uptimeSec": 8 }Conventions
- Base path
/v1. JSON in, JSON out, unless noted. /healthis at the root, not under/v1, and is the only unauthenticated path.- IDs are opaque strings. Never parse them.
- Timestamps are ISO 8601 UTC.
- Every response carries
X-Husk-Version. - Request bodies cap at 32 MB.
X-Forwarded-Foris not trusted. - CORS is off by default.
ServerConfig.corsOriginsturns it on.
Auth
When HUSK_TOKEN is set, every request outside /health needs
Authorization: Bearer <token>. Comparison is constant-time.
When it is not set, every request passes — but the server only ever reaches that state on a loopback bind, because it refuses to start otherwise:
error refusing to bind 0.0.0.0 without an auth token: this API can execute shell commands
hint: set HUSK_TOKEN=$(openssl rand -hex 32) before `husk serve --host 0.0.0.0`, or bind 127.0.0.1Browsers cannot set headers on EventSource or WebSocket, so for those two
transports only — detected by Accept: text/event-stream or an Upgrade: websocket
header — a ?token= or ?access_token= query parameter is accepted.
Errors
{ "error": { "code": "E_COMPUTER_NOT_FOUND",
"message": "no computer with id cmp_x",
"hint": "run `husk ps` to list computers" } }Status is derived from the code: an explicit table first, then two suffix rules.
| Code | Status |
|---|---|
E_NO_CREDENTIALS | 401 |
E_SPEC_INVALID | 422 |
E_QUOTA, E_BUDGET_EXCEEDED | 429 |
E_NOT_IMPLEMENTED | 501 |
E_PROVIDER_UNAVAILABLE, E_MODEL_UNAVAILABLE | 503 |
E_EXEC_TIMEOUT | 504 |
anything ending _NOT_FOUND | 404 |
anything ending _DENIED | 403 |
| everything else | 500 |
The server declares five codes that @husk-ai/core does not: E_HUSK_NOT_FOUND,
E_RUN_NOT_FOUND, E_APPROVAL_NOT_FOUND, E_TRANSCRIPT_NOT_FOUND,
E_ROUTE_NOT_FOUND. They land on 404 through the suffix rule.
Error codes is the full list.
Streaming
Streaming endpoints are Server-Sent Events: Content-Type: text/event-stream, one
data: line per event carrying a JSON object, a : ping comment every 15 seconds, and
a terminating event: done.
A client that stops draining the socket for 60 seconds is treated as gone: the stream closes and the run it was driving is aborted. A closed browser tab stops spending tokens.
Idempotency
POST, PUT, PATCH and DELETE honour an Idempotency-Key header. A replay returns
the recorded status and body with Idempotency-Replayed: true.
The cache is in memory, keyed on <METHOD> <url> <key>, with a 15-minute TTL and a
1000-entry soft cap. It does not survive a restart, and it does not cover streaming
responses — a retried streaming request runs the agent again.
Health and capability
GET /health -> 200 { ok, version, uptimeSec }
GET /v1/doctor -> 200 DoctorReportDoctorReport is one shape with three consumers: this endpoint, @husk-ai/sdk, and
husk doctor --json. Real output from a machine with no keys and no Docker daemon,
abridged:
{
"version": "0.1.0",
"node": "v24.14.0",
"platform": "win32-arm64",
"huskHome": "C:\\Users\\me\\.husk",
"firstRun": false,
"providers": [
{ "name": "docker", "description": "Kernel-level isolation via Docker",
"priority": 20, "available": false, "isolated": true, "isolationKind": "kernel",
"reason": "docker is installed but the daemon is not reachable",
"hint": "start Docker Desktop (or `sudo systemctl start docker`), then re-run `husk doctor`" },
{ "name": "local", "description": "A guarded working directory on this machine. Free, always available, not isolated.",
"priority": 10, "available": true, "isolated": false, "isolationKind": "guardrails",
"version": "WSL2 (Ubuntu)",
"reason": "guarded working directory -- process guardrails, not a sandbox",
"hint": "start Docker for kernel-level isolation" }
],
"models": [
{ "id": "ollama", "displayName": "Ollama", "priority": 40, "available": true,
"envKey": "OLLAMA_HOST",
"models": ["ollama/qwen2.5:1.5b", "ollama/qwen2.5:7b", "ollama/llama3.2:latest"] },
{ "id": "anthropic", "displayName": "Anthropic", "priority": 95, "available": false,
"envKey": "ANTHROPIC_API_KEY", "models": [],
"reason": "ANTHROPIC_API_KEY is not set",
"hint": "Set ANTHROPIC_API_KEY for Claude, or run `ollama pull gemma3` for a free local model." }
],
"selection": {
"provider": "local",
"providerReason": "highest-priority available provider (10)",
"isolated": false,
"model": "ollama/qwen2.5:1.5b",
"modelReason": "first reachable model on Ollama"
},
"warnings": [
"the local provider gives process guardrails, not a sandbox -- do not run untrusted code on it",
"HUSK_TOKEN is not set, so this server only accepts loopback connections"
]
}selection carries the reason alongside the choice, because "husk picked local" is
not actionable on its own. A provider's model list is capped at 12 ids, and is only
requested from providers that answered the availability probe.
GET /v1/doctor?force=true is accepted by the SDK's doctor({ force: true }), but the
route ignores the query parameter — provider status is always fetched with
manager.status(false).
Computers
GET /v1/computers -> { computers: ComputerInfo[] }
POST /v1/computers body: ComputerSpec -> 201 ComputerInfo
GET /v1/computers/:id -> ComputerInfo
DELETE /v1/computers/:id -> 204
POST /v1/computers/:id/stop -> ComputerInfo
POST /v1/computers/:id/start -> ComputerInfoThe create body is validated with a strict schema — an unknown key is a 422, not a
silently ignored typo. A memoryMB that quietly became "no memory limit" is exactly
the kind of failure that costs an hour.
Accepted keys: name, provider, image, flavor, cpus, memoryMb, diskMb,
idleTimeoutSec, maxLifetimeSec, network, env, mounts, workdir, user,
persist, packages, setup, labels.
curl -s localhost:7377/v1/computers \
-H 'content-type: application/json' \
-d '{"name":"scratch","flavor":"python","network":{"mode":"egress","allow":["pypi.org"]}}'More than maxComputers live machines returns E_QUOTA with 429.
Exec
POST /v1/computers/:id/exec
body: { cmd, cwd?, env?, timeoutSec?, stdin?, tty?, user?, maxOutputBytes? }
-> ExecResultcmd is a non-empty string or a non-empty array of strings. timeoutSec is capped at
3600 and maxOutputBytes at 16 MiB. Disconnecting aborts the exec.
Streaming, same body:
POST /v1/computers/:id/exec/stream
data: {"type":"stdout","data":"…"}
data: {"type":"stderr","data":"…"}
data: {"type":"exit","result":{ …ExecResult }}
event: doneFiles
GET /v1/computers/:id/fs?path=/work -> { entries: DirEntry[] }
GET /v1/computers/:id/fs/read?path=/work/a.txt -> raw bytes
PUT /v1/computers/:id/fs/write?path=/work/a.txt body: raw bytes -> 204
DELETE /v1/computers/:id/fs?path=/work/a.txt&recursive=true -> 204fs/read responds application/octet-stream. fs/write takes the raw request body —
send content-type: application/octet-stream and the bytes; a JSON body would be
written as its serialised form. A missing path is a 422 naming the parameter.
POST /v1/computers/:id/fs/upload?path=/work body: a tar.gz -> { path, bytes, entries }
GET /v1/computers/:id/fs/download?path=/work -> application/gzipBoth were listed as not implemented for months, on the grounds that the server has no multipart parser and the repo ships no tar. Both reasons turned out to be answering the wrong question. The tar we need is not on npm — it is already inside the machine, along with gzip, on every image husk supports and on a stock WSL. The archive is made and unmade there; only bytes cross the boundary. That is also the more correct design, since on docker, podman, fly and ssh the host cannot see the directory at all.
No multipart parser either: the body is the archive, not a form containing one.
Download refuses anything over 256 MB compressed, and the size is checked inside the
machine before a byte is read, so an accidental path=/ fails fast instead of pulling a
root filesystem into the server's memory to be rejected afterwards.
husk cp still does directory copies through the provider's own mechanism (docker cp,
scp, a recursive filesystem copy), which stays the faster path when you are at a
terminal on the same host.
Ports and terminal
POST /v1/computers/:id/ports body: { port: 8000 } -> PortBinding
WS /v1/computers/:id/terminalport must be 1–65535. The response is { hostPort, url, publicUrl? }.
The terminal socket is not a pty. Husk ships no native modules, so there is no
node-pty; exec with tty: true is as close as the providers get, and the socket
therefore carries one command per message rather than a persistent shell.
| Direction | Frame |
|---|---|
| server → client | {"type":"ready","computerId":"…","workdir":"/work"} on connect |
| client → server | a raw string, or {"type":"exec","cmd":"ls -la"} |
| client → server | {"type":"resize","cols":120,"rows":32} — recorded, not applied |
| server → client | {"type":"stdout","data":"…"}, {"type":"stderr","data":"…"} |
| server → client | {"type":"exit","exitCode":0,"durationMs":41} |
| server → client | {"type":"error","error":"…"} |
Resize is logged at debug level. Without a pty there is nothing to resize, and saying so beats pretending.
Husks
GET /v1/husks -> { husks: HuskSummary[] }
POST /v1/husks body: { spec } | { yaml } -> 201 HuskSummary
GET /v1/husks/:name -> { spec: HuskSpec, yaml: string }
PUT /v1/husks/:name body: { spec } | { yaml } -> HuskSummary
DELETE /v1/husks/:name -> 204
POST /v1/husks/validate body: { spec } | { yaml } -> { ok, issues? }HuskSummary is { name, displayName, description, model, version, tools, triggers, computer: { enabled, flavor }, updatedAt, runCount }.
PUT requires spec.name to match the path. Renaming is a DELETE then a POST, so
that a rename is visibly two operations rather than an invisible one.
POST /v1/husks/validate is registered ahead of the resource routes so /v1/husks/:name
does not swallow it, and it answers 200 with { ok: false, issues } rather than 422
— it is a validator, not an operation that can fail.
Creating, updating or deleting a husk re-syncs the trigger host, so a new http or
cron trigger takes effect without a restart.
Running a husk
POST /v1/husks/:name/run
POST /v1/husks/:name/run/stream
body: { input, history?, model?, vars?, maxSteps?, maxCostUsd?, approvalMode?, computerId? }input is a string or a ModelMessage[] and is required:
422 { "error": { "code": "E_SPEC_INVALID",
"message": "run requires a non-empty `input`",
"hint": "POST { \"input\": \"your prompt\" } or an array of ModelMessage" } }maxCostUsd is clamped against the husk file — Math.min(body.maxCostUsd ?? spec, spec)
— so a caller can lower the ceiling and cannot raise it. maxTokens and timeoutSec
always come from the spec and are not accepted from the body.
The streaming variant emits RunEvent objects verbatim from @husk-ai/core:
data: {"type":"run_start","runId":"run_x","husk":"triage","model":"ollama/qwen2.5:7b"}
data: {"type":"step_start","step":1}
data: {"type":"tool_start","call":{"type":"tool_call","id":"c1","name":"shell","args":{}}}
data: {"type":"tool_delta","callId":"c1","stream":"stdout","text":"…"}
data: {"type":"tool_end","call":{…},"output":"…","isError":false,"durationMs":812}
data: {"type":"text_delta","text":"Look"}
data: {"type":"usage","usage":{…},"cumulative":{…}}
data: {"type":"run_end","result":{…}}
event: doneThe control plane assigns the run id, not the agent, and rewrites it on run_start and
run_end so one id is true in the stream, in the store, and in the cancel call.
Approvals
With approvalMode: "ask", a dangerous tool call blocks and the stream emits:
{ "type": "approval_required", "approvalId": "apr_x", "request": { … } }GET /v1/approvals -> { approvals: PendingApproval[] }
POST /v1/approvals/:approvalId body: { approve: boolean, remember?: boolean }
-> { approvalId, approved, remembered }An unanswered approval is denied after 120 seconds. Approvals is the full behaviour.
Runs
GET /v1/runs?husk=&limit=50&cursor= -> { runs: RunSummary[], nextCursor? }
GET /v1/runs/:runId -> { result, events }
DELETE /v1/runs/:runId -> 204, cancels first
POST /v1/runs/:runId/cancel -> 202limit is clamped to 1–500 and defaults to 50. cursor is the opaque nextCursor from
the previous page, base-36.
GET /v1/runs/:runId returns the RunResult once the run has finished. While a run
is still in flight it returns the RunSummary in the result slot instead, so a
poller can see status without special-casing a 404. docs/API.md types that field as
RunResult unconditionally.
POST /v1/runs/:runId/cancel is idempotent. An already-finished run answers 202 with
{ runId, cancelled: false, status: "complete" } rather than an error, because a client
racing a completing run should not have to distinguish the two outcomes.
Sessions
GET /v1/sessions/discover?source=&path= -> { sessions: DiscoveredSession[] }
POST /v1/sessions/import -> { transcript: Transcript }
POST /v1/sessions/distill -> { distilled, spec, yaml }
POST /v1/sessions/distill/stream -> SSEimport takes { path? , content?, source? } and needs at least one of path or
content. It saves the transcript and returns the first one it parsed.
distill takes { transcriptId? , transcript?, useModel?, model? }. useModel must be
exactly true to use a model; anything else runs the free heuristic path.
The streaming variant emits three progress frames then the result:
data: {"type":"progress","stage":"scanning","pct":0.1}
data: {"type":"progress","stage":"extracting","pct":0.4}
data: {"type":"progress","stage":"merging","pct":0.8}
data: {"type":"done","spec":{…},"distilled":{…},"yaml":"…"}
event: doneModels
GET /v1/models -> { models: ModelInfo[], providers: [...] }
POST /v1/models/chat body: ChatRequest -> ChatResponse
POST /v1/models/chat/stream body: ChatRequest -> SSE of StreamEventGET /v1/models probes providers four at a time; a dead Ollama does not stall the
picker. Each providers[] entry is
{ id, displayName, priority, available, reason?, hint? }.
The chat body is validated against a strict-ish schema covering model, messages,
system, tools, toolChoice, temperature, topP, maxTokens, stop, thinking,
responseFormat and metadata. messages must be non-empty.
Events
WS /v1/eventsOn connect the server sends a hello frame listing the topics and the currently active
runs. Send {"type":"subscribe","topics":["computers","runs"]} to filter; the server
replies subscribed and then replays the buffered history for those topics before
streaming live traffic. {"type":"ping"} gets a pong.
Topics: computers, runs, providers, triggers, adapters, reaper.
Each frame is { type, at, topic, payload }.
Triggers
Registered once at boot as two wildcard routes plus a listing, with the dispatch tables
rebuilt by sync() whenever a husk changes.
ALL /v1/t/<husk><path> an `http` trigger
POST /v1/w/<husk><path> a `webhook` trigger
GET /v1/triggers -> { triggers: MountedTrigger[], cron: […] }GET /v1/triggers is not in docs/API.md. Triggers covers
prompt extraction, HMAC verification and the cron scheduler.
The console
GET / serves the built dashboard from apps/console/dist when it exists, with a
single-page-app fallback for deep links, and a static placeholder page listing the
useful endpoints when it does not. A missing console never stops the server booting.
The SDK
@husk-ai/sdk is the typed client for this API, and in this release it does not match
the server it ships beside. See SDK for the endpoint-by-endpoint
comparison and what still works.