Husk
GitHub

Chat to bot

Running and serving

husk run, what the loop actually does between steps, and husk serve.

husk run husk.yaml "summarise /work/notes.md"
husk run support-bot --approve ask
husk run husk.yaml "list the files" --json | jq -r .text
FlagWhat it does
--model <m>Override the spec's model
--max-steps <n>Lower the step ceiling for this run
--max-cost <usd>Lower the cost ceiling for this run
--approve <mode>auto, ask, readonly. Default: from the spec
--no-computerRun without giving the agent a machine
--var k=vFill a {{k}} placeholder in the persona. Repeatable
--jsonPrint the run result instead of streaming

Ctrl-C stops the run cleanly between steps and leaves the machine alone. Nothing is destroyed — you interrupted the agent, not the filesystem you were about to inspect.

What a run looks like

husk run

The header names the model actually chosen, not the alias you asked for.

The loop

A conventional tool-calling loop with the boring parts done properly.

Ceilings, checked before each call

CeilingSourceStop reason
Stepslimits.maxSteps, default 24step_limit
Wall clocklimits.timeoutSec, default 300timeout
Costlimits.maxCostUsd, default 0.5budget
Tokenslimits.maxTokens, default 200000budget

The check runs before the next model call, using an estimate of that call, so a run stops rather than overshooting:

the next call would cost about $0.5231, over the $0.5 ceiling
ran out of time after 301s
reached the step ceiling of 24

When the router has no pricing for the chosen model, the estimate falls back to the worst call seen so far in this run — zero on the first call. A model with no catalog entry therefore gets one free call before the budget can see it.

Abort propagates all the way down

One AbortController per run, fed by three things: the wall-clock timer, opts.signal from the caller, and the consumer breaking out of the event stream. It is handed to the model request and to every tool call, so an abort stops the in-flight fetch and the underlying docker exec, not just the loop.

Tools run four at a time

Up to four tool calls execute concurrently, and results are appended in the model's original order regardless of completion order. That is why the transcript above shows the read_file failure printed before the write_file success: the model emitted both in one turn, and the read lost the race.

If a model needs one call to see another's effect, it has to put them in separate turns. Larger models do; small ones sometimes do not.

Every tool result is clamped, then redacted

Clamp first, to limits.maxOutputBytes (default 256 KiB), keeping head and tail. Then redact(), unless guardrails.redactSecrets is false. Clamping first means redaction runs over bounded input.

An agent that cats a .env sees the file. The model sees sk-ant…[redacted].

The loop breaker

Byte-identical consecutive tool turns are counted. The signature is the tool name plus its arguments with object keys sorted, so argument ordering does not hide a repeat.

CountWhat happens
3A warning, and a system nudge appended after the tool results
5The run stops

The nudge is explicit rather than vague:

[husk] You have called shell with byte-identical arguments 3 times in a row and the
result is not changing. Do something different: change the arguments, use another
tool, or tell the user what is blocking you.

At five, the run ends with stopReason: 'error' and code E_TOOL_ERROR. The core type also declares a 'loop' stop reason for exactly this case, and the agent does not use it — a consumer switching on stopReason will see 'error'.

The computer is lazy and shared

ctx.acquireComputer() memoises on the promise, so four parallel tool calls share one machine rather than racing into four. A failed boot clears the memo so a later call can retry.

Stop reasons

complete, timeout, aborted, error, step_limit, budget.

Approvals

husk run support-bot --approve ask

In ask mode a dangerous tool pauses for a human. With no approver wired up, the call is denied, not allowed:

Denied: shell needs approval and no approver is wired to this run
Denied: the operator declined write_file
Denied: write_file can change state and this run is in readonly mode

An approver that throws is treated as a refusal.

husk serve

husk serve
husk serve --port 8080
FlagDefault
--port <n>7377
--host <h>127.0.0.1

husk serve starts the control plane, mounts every trigger the registered husks declare, and starts the reaper on a 60-second interval.

It refuses to start on a non-loopback address without HUSK_TOKEN:

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 <host>`, or bind 127.0.0.1
husk serve

HTTP API is the full route list, and Self-hosting puts this on a box you own.

Running from the library

import { Agent } from '@husk-ai/agent';
import { ModelRouter } from '@husk-ai/models';
import { ComputerManager } from '@husk-ai/runtime';
import { parseSpec } from '@husk-ai/core';
 
const agent = new Agent({
  spec: parseSpec({ name: 'notes', model: 'sonnet', tools: ['computer', 'files'] }),
  router: new ModelRouter(),
  computers: new ComputerManager(),
});
 
const result = await agent.run({
  input: 'Create /work/notes.md with three bullet points, then read it back.',
  onEvent(event) {
    if (event.type === 'text_delta') process.stdout.write(event.text);
    if (event.type === 'tool_start') console.log(`\n[tool] ${event.call.name}`);
  },
});
 
console.log(result.stopReason, result.steps, result.usage.costUsd);

run() is stream() drained to completion, so there is one code path rather than two.

The router and the computer source are structural interfaces, not concrete imports: @husk-ai/agent depends on @husk-ai/core and nothing else in the workspace, so you can pass a fake in a test without a container anywhere near it.