CI is the environment Husk was designed for even though it was designed for laptops: no TTY, no interactivity, exit codes that mean something, and a hard requirement that the free path works.
Two things you might want:
- A computer for a job — a disposable Linux machine to build or test in, driven from a workflow step.
- An agent on a pull request — a husk that reads the diff or the failing log and comments.
Both are below. Everything here runs on ubuntu-latest, and the first one needs no
API key at all.
What CI changes
| No TTY | husk rm and husk init need --yes. --approve ask refuses to start |
| Colour | Off automatically when piped; NO_COLOR also honoured |
| Exit codes | 0 ok, 1 error, 2 usage, 130 interrupted. husk exec passes the child's code through |
| stdout vs stderr | husk run puts the answer on stdout and everything else on stderr |
| Docker | Preinstalled on ubuntu-latest, so you get kernel isolation for free |
| Telemetry | None. Nothing leaves the runner except calls to the model provider you configure |
A computer for a job
name: husk computer
on: [push]
jobs:
build-in-a-box:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm install -g @husk-ai/cli
# Read this in the log before anything else fails mysteriously.
- name: what can this runner do
run: husk doctor
- name: create a machine
run: husk up ci --provider docker --flavor node --network full
- name: use it
run: |
husk cp ./ ci:/work/repo
husk exec ci --cwd /work/repo -- npm ci
husk exec ci --cwd /work/repo -- npm test
- name: get the artifact back
run: husk cp ci:/work/repo/coverage ./coverage
- name: always clean up
if: always()
run: husk rm ci --yeshusk exec exits with the command's own exit code, so a failing npm test fails the
step. That is the whole reason it does not wrap the code.
Choosing the provider
--provider docker on a GitHub-hosted runner gives you real kernel isolation, which is
what you want for anything touching a fork's code:
✓ docker isolated
Kernel-level isolation via Docker
via 24.0.7--provider local also works and needs nothing, which is why the Husk repo's own CI
uses it for the smoke test — it proves the free path is genuinely free. On a runner the
distinction matters less than on a laptop, because the runner is disposable anyway.
Keeping state off the runner's home directory
env:
HUSK_HOME: ${{ github.workspace }}/.huskEverything Husk writes — machine records, workspaces, run events — then lands inside
the workspace, where actions/cache can see it and where nothing survives the job.
An agent on a pull request
name: husk review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm install -g @husk-ai/cli
- name: the diff
run: git diff --unified=3 "origin/${{ github.base_ref }}...HEAD" > /tmp/diff.patch
- name: review it
id: review
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
husk run .github/husk/reviewer.yaml \
--model sonnet \
--max-steps 6 \
--max-cost 0.20 \
--approve readonly \
"$(cat /tmp/diff.patch)" > /tmp/review.md
- name: comment
env:
GH_TOKEN: ${{ github.token }}
run: gh pr comment "${{ github.event.number }}" --body-file /tmp/review.mdapiVersion: husk/v1
name: pr-reviewer
description: Reviews a diff and names the one thing most likely to be wrong.
model: sonnet
fallbackModels: [flash]
persona: |-
You review a unified diff from a TypeScript monorepo.
Name the single change most likely to be a bug, quote the line, and say why.
If nothing is wrong, reply with exactly: LGTM.
No summary of the diff. The author wrote it; they know what is in it.
tools: [] # a reviewer reads the prompt, not the filesystem
computer:
enabled: false # so no machine is ever created
limits:
maxSteps: 4
maxCostUsd: 0.20
maxTokens: 120000
timeoutSec: 180
guardrails:
approvalMode: readonlyThree deliberate choices in that file.
tools: [] and computer.enabled: false. A reviewer that only reads its prompt
cannot be prompt-injected into running a command, because it has no command to run.
This is the strongest control available and it costs one line.
approvalMode: readonly. Belt and braces: even if a tool were added later, nothing
dangerous can run. ask would be wrong here — it
fails closed with no approver, which means it
fails, loudly, on every run.
maxCostUsd: 0.20. Checked before each model call. A pathological 12,000-line
diff stops rather than billing.
Reading the answer
husk run streams the model's text to stdout and every step, tool call and warning
to stderr, so a plain redirect captures the answer and nothing else. For a
structured result:
husk run reviewer.yaml "$(cat /tmp/diff.patch)" --json > /tmp/result.json
jq -r '.text' /tmp/result.json
jq -r '.stopReason' /tmp/result.json # complete | step_limit | budget | timeout | …
jq -r '.usage.costUsd' /tmp/result.jsonFail the job when the run was cut short rather than when it merely disagreed with you:
STOP=$(jq -r .stopReason /tmp/result.json)
[ "$STOP" = complete ] || { echo "::warning::husk stopped early: $STOP"; }Secrets
Model keys go in repository secrets and reach Husk through env: on the step that
needs them:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}They stay on the runner. Husk's environment scrub rebuilds a command's environment from a small allow-list, so a key in the runner's environment does not reach a command running inside a computer. Pass one in deliberately if a build genuinely needs it:
computer:
env:
NPM_TOKEN: ${NPM_TOKEN}The free path, in CI
No key, no Docker, no account. This is close to the Husk repository's own smoke job, which exists so that a regression in the free path fails the build:
- name: doctor reports honestly with nothing configured
run: husk doctor --json | jq '.selection, .warnings'
- name: a local computer works with no Docker and no API key
run: |
set -euo pipefail
husk up ci-smoke --provider local
husk exec ci-smoke -- 'echo hello from husk > /work/a.txt'
test "$(husk exec ci-smoke -- 'cat /work/a.txt')" = 'hello from husk'
husk ps
husk rm ci-smoke --yes
- name: the command policy refuses what it promises to refuse
run: |
if husk up deny-check --provider local &&
husk exec deny-check -- 'sudo rm -rf /'; then
echo "deny list did not fire" >&2; exit 1
fi
husk rm deny-check --yes
- name: shipped husks validate
run: for f in .github/husk/*.yaml; do husk validate "$f"; donehusk validate in CI is the cheapest possible guard against a husk.yaml that stopped
parsing. It exits 1 and lists every problem at once.
Caching
There is nothing Husk-specific to cache. The two costs are the npm install and the image pull:
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm } # caches the npm download cacheDocker layer caching between jobs is the usual docker/setup-buildx-action story and
is unrelated to Husk.
Other CI systems
Nothing here is GitHub-specific. Husk is a Node CLI with no daemon and no account.
husk:
image: node:22
script:
- npm install -g @husk-ai/cli
- husk doctor
- husk up ci --provider local
- husk exec ci -- npm test
- husk rm ci --yesInside a container without a Docker socket, --provider docker is unavailable and
local is the floor that is always there — which is the whole point of it being the
floor. Read husk doctor in the log rather than assuming.
Troubleshooting
refusing to destroy N computers without confirmation on a non-interactive stdin —
add --yes to husk rm.
--approve ask needs a terminal to ask on — use auto or readonly.
no model provider is reachable — the secret is not on that step. env: is
per-step unless you set it at the job or workflow level.
provider "docker" is not usable — you are inside a container without a Docker
socket. Use --provider local, or mount the socket, understanding that mounting the
host's Docker socket into a job is equivalent to giving that job root on the host.
A step hangs. husk exec defaults to a 120-second timeout and kills the whole
process group; a run defaults to 300 seconds. Neither should hang. If one does, it is
before either timer — most likely an image pull. Add --debug.