Husk
GitHub

Chat to bot

husk.yaml reference

Every field of the spec, its type, its default and what it does — taken field by field from the zod schema in packages/core/src/spec.ts.

husk.yaml is the whole agent in one file: portable, diffable, reviewable, and the only thing the runtime needs to bring a bot up. It is validated by a zod schema, so an invalid file fails with every problem listed at once rather than the first one.

husk init                    # scaffold one
husk validate                # check ./husk.yaml
husk validate support.yaml   # or a named file

The smallest valid file

name is the only required field. Everything else has a default.

name: my-bot

That expands to a husk on model: auto with an empty persona, the computer tool bundle, a cli trigger, and every limit at its default.

Top level

FieldTypeDefaultWhat it does
apiVersion"husk/v1"husk/v1Schema version. Bumped only on a breaking change
namestringrequired1–64 chars, ^[a-z0-9][a-z0-9-]*$. The URL and the CLI handle
displayNamestring ≤ 96Human label
versionstring0.1.0Your version, not Husk's
descriptionstring ≤ 280""One line
modelstringautoAn alias or a provider/model id
fallbackModelsstring[][]Tried in order when the primary is unavailable, rate limited or over budget
temperaturenumber 0–2Passed to the provider when set
personastring""The system prompt. Supports {{var}} interpolation
knowledgeobject[][]Stable reference text, appended to the prompt
examplesobject[][]Few-shot pairs
toolsstring[]['computer']Bundle names or individual tool names
computerobjectall defaultsThe machine. See below
limitsobjectall defaultsCeilings on a run
guardrailsobjectall defaultsApproval mode, deny list, refusals
memoryobjectall defaultsHistory trimming
triggersarray[{ type: cli }]How the husk is reached
metadatarecord{}Free-form, carried through untouched
originobjectProvenance. Written by the distiller

persona

The system prompt. {{var}} placeholders are interpolated from RunOptions.vars (husk run --var key=value); an unknown variable is left intact rather than blanked.

At run time Husk appends two sections when they have content:

  • ## Reference — one ### <title> block per knowledge item.
  • ## Out of scopeDecline these, briefly, and say what you can do instead: followed by one bullet per guardrails.refuse entry.

knowledge

knowledge:
  - title: Repository layout
    content: |
      packages/* are libraries, apps/* are deployables.
    source: https://github.com/org/repo   # optional, free-form

title and content are required; there is no length cap in the schema.

examples

examples:
  - user: main is red
    assistant: >
      The `build (node 20)` job failed at `packages/core` with TS2345.

Both sides required.

tools

Bundle names expand; individual names are also accepted.

BundleTools
computershell, expose_port, computer_info
filesread_file, write_file, edit_file, list_dir, search_files, move, delete
webfetch_url, and web_search when TAVILY_API_KEY or BRAVE_API_KEY is set
httphttp_request

A tool that cannot work is skipped rather than registered-and-broken: a tool the model can see is a promise that calling it will do something. Tools needing a machine are dropped when computer.enabled is false, and http_request is opt-in — naming the http bundle counts as naming it.

computer

computer:
  enabled: true
  flavor: python
  packages: [pandas, numpy]
  network:
    mode: egress
    allow: ['pypi.org', 'files.pythonhosted.org']
  memoryMb: 4096
  idleTimeoutSec: 600
  persist: true
  setup: mkdir -p /work/out
FieldTypeDefaultWhat it does
enabledbooleantruefalse disables the machine entirely
providerstringbest availabledocker, podman, local, ssh, fly
flavorenumbasebase, python, node, full
imagestringExplicit image reference. Overrides flavor
cpusnumber > 0, ≤ 642 at create timeCPU allocation
memoryMbint > 0, ≤ 1310722048 at create timeMemory ceiling
diskMbint > 02048 at create timeSize of the writable /work layer
idleTimeoutSecint ≥ 0900Destroy after this long idle. 0 disables
maxLifetimeSecint ≥ 00Hard lifetime ceiling. 0 disables
networkobject{ mode: 'egress' }See below
packagesstring[][]Installed on first boot by the flavor's package manager
setupstringA shell snippet run once after creation
envrecord{}Variables passed in explicitly
workdirstring/workDefault working directory
persistbooleanfalseKeep the filesystem across restarts
mountsobject[][]Host paths. readonly defaults to true
userstringprovider's defaultUnprivileged user inside the machine
labelsrecordProvider-specific overrides. See below

computer.labels

Per-husk provider configuration, for the two providers that point at something outside this machine. Set here, a husk.yaml needs no environment variables at all:

computer:
  provider: ssh
  labels:
    husk.ssh: deploy@build-box:2222
    husk.ssh.key: ~/.ssh/id_ed25519
LabelProviderEnvironment equivalent
husk.sshsshHUSK_SSH_TARGET
husk.ssh.keysshHUSK_SSH_KEY
husk.fly.appflyHUSK_FLY_APP / FLY_APP_NAME
husk.fly.regionflyHUSK_FLY_REGION / FLY_REGION
husk.fly.portsfly

The label wins over the environment, so one machine can drive several boxes. Values are strings; anything else is rejected at parse time. Husk adds a husk.key label of its own when it binds a machine to a session — do not set that one.

computer.network

FieldTypeDefaultWhat it does
modenone | egress | fullegressSee Networking
allowstring[]Hostnames. A single leading *. wildcard is supported
denystring[]Always refused, including in full

An egress policy with no allow list permits nothing.

computer.mounts

mounts:
  - source: ./data
    target: /work/data
    readonly: true

source and target are required. readonly defaults to true — a host mount is the one hole in a container, and an agent that only needs to read a repository must not rewrite it.

computer.env

Explicit values always win over the environment scrub, which is how you pass a credential deliberately:

computer:
  env:
    GITHUB_TOKEN: ${GITHUB_TOKEN}

Explicit beats implicit, and now it is in the file where a reviewer can see it.

limits

limits:
  maxSteps: 20
  maxCostUsd: 0.25
  timeoutSec: 240
FieldTypeDefaultWhat it does
maxStepsint 1–50024Ceiling on tool-calling rounds
maxCostUsdnumber ≥ 00.5Ceiling on estimated spend for the run
maxTokensint > 0200000Ceiling on total tokens
timeoutSecint > 0300Wall clock for the whole run
execTimeoutSecint > 0120Per-exec ceiling inside the machine
maxOutputBytesint > 0262144Per-tool-result cap, before redaction

Every ceiling is checked before the next model call, not after, so a run stops rather than overshooting. See Cost and budgets for what the cost estimate can and cannot know.

guardrails

guardrails:
  approvalMode: auto
  denyCommands:
    - 'git\s+(push|commit|reset\s+--hard)'
  refuse:
    - deploying, reverting, or merging anything
FieldTypeDefaultWhat it does
approvalModeauto | ask | readonlyautoSee below
denyCommandsstring[][]Regexes matched against a shell command before it runs
allowCommandsstring[][]Regexes that override both your deny list and the built-in one
refusestring[][]Topics to decline, appended to the prompt as ## Out of scope
redactSecretsbooleantruePass every tool result through redact() before the model sees it

allowCommands wins over denyCommands and over the built-in deny list: an explicit allow entry is you saying you know better about your own machine, and you do. An entry that is not valid regex falls back to a substring match rather than throwing.

approvalMode

ModeBehaviour when a dangerous tool is called
autoIt runs
askThe run pauses for an approver. With no approver wired up, the call is denied
readonlyDenied, approver or not

ask fails closed. A headless server running an ask-mode husk denies rather than allows, which is the only safe default for a mode whose whole point is a human in the loop.

Dangerous tools: shell, write_file, edit_file, move, delete, expose_port, http_request. Not dangerous: read_file, list_dir, search_files, computer_info, fetch_url, web_search.

memory

memory:
  enabled: true
  windowTurns: 20
  summarise: true
FieldTypeDefaultWhat it does
enabledbooleanfalseThe only gate. Off, and history is never trimmed
backendsqlite | memory | filesqliteNot read by any code
windowTurnsint > 020Turns kept verbatim before older ones are summarised
summarisebooleantrueSummarise the elided middle instead of dropping it

With enabled: true, the loop keeps the system messages, the first user message as an anchor, and the last windowTurns turns. The middle is either replaced by a model-written summary or by a marker:

[husk: 42 earlier messages summarised]

[husk: 42 earlier messages elided to stay inside the context window]

Cuts land on turn boundaries and orphaned pairs are pruned, so a tool_call is never separated from its tool_result. The summary call is charged to the run's budget.

triggers

triggers:
  - type: http
  - type: cron
    schedule: '*/15 * * * *'
    prompt: Any red builds on main in the last 15 minutes?

The schema accepts seven types. Three are wired into the server — see Triggers for which and what happens to the rest.

TypeFields
httppath (default /), auth: none | token (default token)
cronschedule required, prompt required
discordchannels (default []), mentionOnly (default true)
slackchannels (default []), mentionOnly (default true)
telegramallowlist (default [])
webhookpath required, secret optional
clinone

metadata and origin

metadata is a free-form record carried through untouched. Husk itself writes exactly two keys into it, both from distillation:

KeyType
distilledConfidencenumber, 0–1how much the distiller trusts what it extracted
distillerNotesstring[]what it had to guess at, and why

These names are the contract. Every path that distils — husk distill, POST /v1/sessions/distill, and toSpec in @husk-ai/sessions directly — goes through the one mapper and writes these two. Nothing writes distillConfidence or distillNotes; if you are reading a husk.yaml written by an older build, those are the same two values under the old spellings.

Everything else under metadata is yours, and husk will not touch it.

origin records provenance. It survives a round trip through husk validate and back out to YAML, so a distilled husk can always say where it came from:

FieldType
sourcestringrequired within origin. The transcript's origin (its file path) when it has one, otherwise its source (claude-code, chatgpt, …)
transcriptIdstringoptional
importedAtstringoptional
messageCountnumberoptional

A complete example

ci-triage.yaml
apiVersion: husk/v1
name: ci-triage
displayName: CI Triage
version: 0.2.0
description: Reads failing CI logs and says what broke, in one paragraph.
 
model: sonnet
fallbackModels: [flash, gemma]
 
persona: |
  You triage CI failures for a TypeScript monorepo.
 
  Always read the failing job's log before forming an opinion. Quote the first
  line that actually failed -- not the last line, which is usually the runner
  giving up.
 
  Answer in one paragraph. No preamble.
 
knowledge:
  - title: Repository layout
    content: |
      packages/* are libraries, apps/* are deployables. Any change under
      packages/core forces a full rebuild.
 
tools: [computer, files]
 
computer:
  flavor: node
  network:
    mode: egress
    allow: ['*.github.com', 'objects.githubusercontent.com']
  idleTimeoutSec: 600
 
limits:
  maxSteps: 20
  maxCostUsd: 0.25
  timeoutSec: 240
 
guardrails:
  approvalMode: auto
  denyCommands:
    - 'git\s+(push|commit|reset\s+--hard)'
    - 'gh\s+(pr|release)\s+(create|merge|edit)'
  refuse:
    - deploying, reverting, or merging anything
 
triggers:
  - type: http
  - type: cron
    schedule: '*/15 * * * *'
    prompt: Any red builds on main in the last 15 minutes? If none, reply exactly "clear".

Validation errors

Every problem at once, each as path: message:

error husk.yaml is not a valid husk
  ✗ name: name must be lowercase alphanumeric with dashes
  ✗ limits.maxSteps: Number must be less than or equal to 500
  ✗ triggers.0.schedule: Required
hint:  `husk init --force` regenerates a valid file, or see `husk help init`

The same check is available over the control plane at POST /v1/husks/validate, which returns { ok, issues } with HTTP 200 either way — it is a validator, not an operation that can fail.