Husk
GitHub

Reference

HTTP API

Every route husk serve actually registers, its real request and response shape, and the four places docs/API.md describes something the server does not do.

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.
  • /health is 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-For is not trusted.
  • CORS is off by default. ServerConfig.corsOrigins turns 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.1

Browsers 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.

CodeStatus
E_NO_CREDENTIALS401
E_SPEC_INVALID422
E_QUOTA, E_BUDGET_EXCEEDED429
E_NOT_IMPLEMENTED501
E_PROVIDER_UNAVAILABLE, E_MODEL_UNAVAILABLE503
E_EXEC_TIMEOUT504
anything ending _NOT_FOUND404
anything ending _DENIED403
everything else500

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 DoctorReport

DoctorReport 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  -> ComputerInfo

The 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? }
  -> ExecResult

cmd 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: done

Files

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  -> 204

fs/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/gzip

Both 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/terminal

port 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.

DirectionFrame
server → client{"type":"ready","computerId":"…","workdir":"/work"} on connect
client → servera 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: done

The 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           -> 202

limit 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          -> SSE

import 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: done

Models

GET  /v1/models              -> { models: ModelInfo[], providers: [...] }
POST /v1/models/chat         body: ChatRequest -> ChatResponse
POST /v1/models/chat/stream  body: ChatRequest -> SSE of StreamEvent

GET /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/events

On 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.