Model pipeline (.hutashm) authoring — schema reference
Audit-based reference, produced 2026-09-12. Every field name and every behavior below is verified against the actual Go parser (hutash-daemon/daemon/ext/packages/spec.go and reader.go) or a real…
Model pipeline (.hutashm) authoring — schema reference
Audit-based reference, produced 2026-09-12. Every field name and every
behavior below is verified against the actual Go parser
(hutash-daemon/daemon/ext/packages/spec.go and reader.go) or a real
package's actual on-disk content as published in hutash-registry
— the public distribution repo, and therefore the actual bytes a real
install downloads — never against what a doc says the shape is,
unless that doc was cross-checked against the parser too. (hutash-app
is the private authoring repo the same package is built in; as of this
writing its copy is a byte-identical mirror of hutash-registry's, but
hutash-registry is cited throughout as the source of truth because it
is what's actually distributed — an authoring-repo copy could in
principle drift from what's shipped, and be silently wrong to cite.)
Where a documented example and the real parser disagree, that's called
out explicitly (§5) rather than silently resolved one way. This is
Phase 1 source material for a future coding-agent-facing AGENTS.md —
not that document itself.
Relationship to existing docs: hutash-os's
docs/reference/HUTASH_FORMAT.md (v1.4.1) is the actively-maintained
format spec and docs/reference/create-model-pipeline.md is its
tutorial companion — both are good and this document does not replace
either. What this document adds, verified fresh against the parser
rather than assumed from those docs:
- The exact, field-by-field schema the engine's parser actually reads — confirmed required vs. optional at the code level, not just by convention.
- Today's vendor-aware
SelectVariantlogic and the SHA-256 verification field — both added to the engine afterHUTASH_FORMAT.mdv1.4.1 and not documented anywhere yet. - One real, complete, shipped package (
opus-mt-en-hi) quoted in full across all four files and annotated. - Two concrete discrepancies found between the documented examples and what the parser/real files actually use (§5) — worth fixing in the source docs, not silently worked around here.
1. The engine's actual parse-time contract
ReadPackage (daemon/ext/packages/reader.go:62): only
manifest.yaml is strictly required, and within it only id.
Everything else — packages.yaml, launch.yaml, weights.yaml — is
read if present (readOptionalYAML) and simply left at its zero value
if absent. A missing manifest, or one with no id, is the only thing
that makes ReadPackage return an error at all (lines 65-72). This is
a deliberately permissive read-side contract; it does not mean every
field is optional in the sense of "the package will actually work" —
only that the parser itself won't reject a package for lacking one.
manifest.yaml — every field applyManifest reads (reader.go:134-179)
| Field | Go type | Source | Notes |
|---|---|---|---|
id |
string | — | The only field whose absence is a parse error. |
name |
string | — | |
version |
string | — | |
type |
string | normalized via normalizeTypeKeep |
model_pipeline/model/models all normalize to pipeline; application normalizes to app (types.go:36-46). An unrecognized value is kept verbatim and logged once — not rejected. |
runtime |
string | — | venv is the only value the engine actually installs today (coordinator.go: any other value fails with "not supported yet"). |
gpu |
string | — | required | optional | none |
min_vram_gb |
float | min_vram_gb, falls back to ui.hardware.min_vram_gb if the top-level key is ≤0 |
The fallback exists because some manifests only set the real number under ui.hardware (reader.go:146-153) |
recommended_vram_gb |
float | — | |
min_ram_gb |
float | — | |
required_cpu_features |
CPUFeatureRequirements (variant → list) |
{gpu: [...], cpu: [...]} per install variant, or a flat list for every variant |
The CPU instruction sets the package's compiled binaries execute unconditionally, in bhoomi's names (avx, avx2, f16c, fma, avx512f, avx512bw, avx512dq, avx512vl, bmi2, avx_vnni, …). Declare only what the wheel was built with — read it, don't guess: for llama.cpp, the exported ggml_cpu_has_* functions in ggml-cpu.dll return compile-time constants. The engine resolves the list for the variant a host would install, reports what the CPU lacks as missing_cpu_features on GET /catalogue and GET /packages, and refuses the install (403) when anything is missing; apps show the model as incompatible. Omit it when nothing beyond baseline x86-64 is required. build_index.py publishes it with one key per packages.yaml variant. |
capabilities |
[]Capability{ID, Modality} |
list of {id, modality} |
|
description |
string | — | |
license |
string | bare SPDX string | A v2.0 package's license lived in interface/manifest.json instead — reading a map here yields "", which is treated as "that package's license travels with its own UI contract," not an error |
min_os_version |
string | — | Optional; empty means "runs anywhere," never "unknown." The engine relays it and never compares it — see PackageSpec.MinOSVersion's own doc comment for the full rationale (comparison is an application-layer concern) |
source |
*SourceSpec{Repo, Commit} |
source: map |
Applications only, per HUTASH_FORMAT.md §2.4 — a pipeline's code ships inside the package |
ui |
map[string]any |
ui: key, carried as an opaque tree |
The engine never interprets this — it writes it back out as application/manifest.json at install time (InterfaceManifest()) for the model server / Studio to read. Full shape: HUTASH_FORMAT.md §5 |
application/packages.yaml — every field applyPackages reads (reader.go:218-232)
| Field | Go type | Notes |
|---|---|---|
python |
string | |
common |
[]string |
Installed regardless of hardware |
variants |
map[string]VariantSpec{Packages []string, Indexes []string} |
Keyed by variant name — gpu and cpu are the two the engine's SelectVariant (§2 below) actually looks for; any other key is accepted but only reachable via the fallback branch |
system_packages |
[]string |
OS-level packages (e.g. build-essential) |
There is no extra_index_urls: or system: top-level key that the
parser reads — see §5, discrepancy 1.
application/launch.yaml — every field applyLaunch reads (reader.go:234-244)
| Field | Go type | Notes |
|---|---|---|
command |
string | The executable to run (e.g. uvicorn) |
args |
[]string |
Passed to command |
env |
map[string]string |
{port}, {model_dir}, {weights_dir} are template placeholders the engine fills in at launch |
port |
int | The port {port} in args/env resolves to |
health_endpoint |
string | |
health_timeout |
int (seconds) |
There is no entrypoint: or health: key that the parser reads —
see §5, discrepancy 2. launch.yaml exists for type: model_pipeline
only.
resources/weights.yaml — every field applyWeights reads (reader.go:246-266)
| Field | Go type | Notes |
|---|---|---|
sources (and extra, same shape, appended to the same list) |
[]Weight{Repo, Revision, AllowPatterns []string} |
An entry with an empty repo is silently skipped |
download_size_gb |
float | Display-only |
revision should be a pinned commit, not a branch name — HUTASH_FORMAT.md §6's "pin everything" principle, and confirmed practice in every real weights.yaml this session inspected.
2. packages.yaml's variant system — today's real, vendor-aware SelectVariant
PackageSpec.SelectVariant (spec.go:133-175) — the actual algorithm
the engine runs at install time to pick gpu or cpu from a
package's declared variants:. Quoted here because it changed
recently (2026-09-12, commit 9b36aaa in hutash-daemon) and is not
documented in HUTASH_FORMAT.md at all — that spec only documents the
declaration shape, not the selection logic:
- Both
gpuandcpudeclared:gpuwins only when the host has an NVIDIA GPU (hw.GPUVendor == resource.GPUNVIDIA) and its VRAM meetsmin_vram_gb(a package with no declared minimum is satisfied by GPU presence alone). The vendor check gates before the VRAM comparison, not alongside it — a real GPU that isn't NVIDIA (AMD, Intel, or any future vendor) cannot run the CUDA-specificgpuvariant regardless of how much VRAM it reports. This also correctly routes Apple's unified-memory "GPU" tocpu: its reported VRAM is the host's entire system RAM (bhoomi's own convention), which would otherwise satisfy almost any VRAM floor and wrongly select a CUDA variant on hardware with no CUDA at all. - Otherwise
cpu— no GPU, a non-NVIDIA GPU, or an NVIDIA GPU below the declared VRAM floor. - Only one of
gpu/cpudeclared: that one, unconditionally, regardless of hardware. - Neither declared:
""(common-only package) — or, if a non-standard variant key exists, that key (the "fall through to whichever single variant is declared" branch).
A package can declare both gpu and cpu pointing at the identical
index/packages — confirmed real practice, not a hypothetical: both
opus-mt-en-hi and kokoro's variants.gpu and variants.cpu point
at the same https://download.pytorch.org/whl/cpu index with an empty
packages: [] list, because gpu: none at the manifest's top level
means the CUDA variant would never actually be reachable in practice —
but SelectVariant doesn't consult gpu: at all, only the presence of
the variants map keys, so a package with gpu: none and duplicate
gpu/cpu entries still runs SelectVariant's full vendor-check branch.
This is harmless (both branches install byte-identical dependencies)
but is a real, observed pattern worth knowing rather than assuming a
bug when you see it.
3. SHA-256 package verification — where it lives and why
Not in the package's own manifest.yaml. The expected digest lives
in the catalogue (hutash-app's published index.json,
CatalogueEntry.SHA256 / CatalogueVersion.SHA256,
daemon/ext/packages/catalogue.go), verified by the installer
immediately after download and before extraction
(installer.go's fetch, via apptypes.VerifyHutashSHA256).
The reason, quoted from catalogue.go's own doc comment because it's
worth getting right rather than re-paraphrasing: verifying a zip's
own integrity using a value that is only readable AFTER extracting
that same zip is circular — by the time you could read a hash from
inside the archive, you have already trusted and unpacked the thing you
meant to check first. The hash has to come from somewhere fetched
before the archive, independent of it — index.json is exactly that,
already fetched ahead of any package's own download the same way its
URL is.
An empty/absent sha256 (every package published before this field
existed) is not a failure — VerifyHutashSHA256 treats "nothing to
check against" as a pass, not a warning suppressed. hutash-app's
scripts/build_index.py computes this hash fresh on every rebuild,
directly from the file each entry points at, so it can never go stale
relative to what a client would actually download.
4. Capability declaration — how a pipeline says "I provide X"
manifest.yaml's ui.capabilities block (§1's ui field, opaque to
the engine, interpreted by Studio/verticals/CLI/MCP) is a map keyed
by capability id — capabilities: {translate: {...}}, not a list —
per create-model-pipeline.md's own correct note and confirmed against
opus-mt-en-hi's real manifest below (ui.capabilities.translate).
Each entry's endpoint: is the real HTTP path a caller must use — it
is never assumed to equal the capability id (opus-mt-en-hi itself
declares endpoint: /translate, which happens to match its id, but
qwen3-1.7b's llm capability is served at /generate, and
callers always resolve the endpoint from the manifest rather than
guessing).
This is the OTHER end of the resolution trace documented in full in
hutash-os's docs/reference/application-authoring-reference.md §2 —
a vertical's capabilities_needed + ${settings.X_model} resolves TO
a package id, and that package's own manifest.yaml.ui.capabilities
entry is what supplies the actual endpoint the resolved call hits.
Top-level capabilities: [{id, modality}] (§1's table — a short list,
not the full ui.capabilities map) is what the catalogue's
?capability= filter and get_declared_capabilities() actually read
to answer "does this package do X" without needing the full UI
contract — the two are parallel, not duplicate: the short list answers
whether, the ui.capabilities map answers how.
5. Two verified discrepancies between the documented examples and reality
1. packages.yaml's documented example shape does not match the
parser or any real shipped file. HUTASH_FORMAT.md §4 shows:
packages:
gpu: [torch==2.11.0+cu128, kokoro==0.9.4]
cpu: [torch==2.11.0+cpu]
common: [fastapi]
extra_index_urls:
gpu: [https://download.pytorch.org/whl/cu128]
system:
apt: [espeak-ng]
But reader.go's applyPackages reads common (flat list, top-level
— not nested under a packages: key at all), variants.<name>.packages
variants.<name>.indexes, andsystem_packages(flat list, notsystem.apt). Every real shipped file this session inspected (opus-mt-en-hi,kokoro,whisper-tiny) uses the parser's real shape, e.g.:
python: '3.12'
common:
- hutash-inference>=0.2.2
- transformers==4.46.3
variants:
gpu:
packages: []
indexes:
- https://download.pytorch.org/whl/cpu
cpu:
packages: []
indexes:
- https://download.pytorch.org/whl/cpu
system_packages:
- build-essential
HUTASH_FORMAT.md's example should be corrected — an authoring agent
that trusted it literally would produce a packages.yaml the real
parser reads as having no dependencies at all (common nested one
level too deep, variants absent, system_packages absent).
2. launch.yaml's documented example shape does not match the
parser or any real shipped file either. Both HUTASH_FORMAT.md §4
and create-model-pipeline.md §3 show:
entrypoint: inference.py
port: 0
health: /health
env: {HF_HUB_OFFLINE: "1"}
But reader.go's applyLaunch reads command, args,
health_endpoint, health_timeout — it does not read entrypoint
or health at all. Every real shipped file uses:
command: uvicorn
args: [--factory, hutash_inference.server:create_app, --host, 127.0.0.1, --port, '{port}']
env:
HUTASH_MODEL_ID: opus-mt-en-hi
HUTASH_MODEL_DIR: '{model_dir}'
HF_HUB_CACHE: '{weights_dir}'
HF_HUB_OFFLINE: '1'
port: 8000
health_endpoint: /health
health_timeout: 120
This is the more serious of the two discrepancies: a launch.yaml
written to the documented shape would parse as spec.Command == ""
and spec.HealthEndpoint == "", which is not a parse error but would
fail whatever start step actually tries to invoke spec.Command. This
should be corrected in both source docs before an authoring agent is
built against them.
6. Worked example — opus-mt-en-hi, in full
All four real files, verbatim, extracted fresh from
hutash-registry/pipelines/opus-mt-en-hi.hutashm — the actual
distributed package — as of the 2026-09-12 rename. Byte-for-byte
identical to hutash-app's copy of the same file, confirmed by direct
diff at documentation time.
manifest.yaml
hutash_format: '1.0'
id: opus-mt-en-hi
name: Opus-MT English-Hindi
version: 1.0.4
naming:
family: opus-mt
version: null
variant: en-hi
parameters: 77m
quantization: null
format: pytorch
type: model_pipeline
gpu: none
min_ram_gb: 1.0
license: Apache-2.0
min_os_version: "1.0.2"
capabilities:
- id: translate
modality: translate
platforms: [windows, linux]
description: Lightweight English-to-Hindi machine translation, CPU-only.
metadata:
weight_category: text
quality_score: 0.8
ui:
model_id: opus-mt-en-hi
display_name: Opus-MT English-Hindi
license:
spdx: Apache-2.0
url: https://huggingface.co/Helsinki-NLP/opus-mt-en-hi
commercial_ok: true
attribution_required: false
attribution_text: null
capabilities:
translate:
label: Translate
description: Translate a .txt or .srt document, or inline text, from English to Hindi
primary: true
endpoint: /translate
status_message: Translating…
inputs:
file:
type: text_file
label: Document to translate
accept: [text/plain, .txt, .srt]
required: false
clear_after_generate: true
text:
type: string
label: Or paste text to translate
description: Translated inline. Leave the document above empty when using this.
required: false
max_length: 5000
clear_after_generate: true
controls:
source_language:
type: enum
label: Source language
default: en
options: [{value: en, label: English}]
target_language:
type: enum
label: Target language
default: hi
options: [{value: hi, label: Hindi}]
outputs:
translated_text: {type: text, role: primary}
hardware: {min_vram_gb: 0, recommended_vram_gb: 0, supports_cpu: true, total_install_gb: 1}
tagline: "Opus-MT — lightweight CPU English-to-Hindi translation"
creator:
name: Language Technology Research Group at the University of Helsinki
url: https://huggingface.co/Helsinki-NLP
source:
upstream_repo: https://github.com/Helsinki-NLP/Opus-MT
huggingface_repo: Helsinki-NLP/opus-mt-en-hi
api: {health: /health, gpu: false}
ui:
primitives:
- {type: quick_text_transform, source_label: Source text, target_label: Translation, action_label: Translate}
layout: aggregator-3panel
Annotated:
inputs.fileandinputs.textare bothrequired: false— deliberately. The real file's own comment (preserved above) explains why:inference.py'stranslate()accepts exactly one of the two and raises if given neither, but the manifest itself can't express "exactly one of" — declaring either onerequired: truewould 400 before the model ever ran, on the legitimate call that used the other. The "exactly one" rule lives in the one place that actually knows it: the inference code itself.controls.source_language/target_languageeach have exactly one option — this model translates one fixed language pair, but still declares both controls (rather than omitting them) so it presents the same Source/Target pair every "translate"-capability model shows on the Translate page;inference.pyignores both values.metadata.notesin the real file (omitted above for length) confirms this is a deliberate UI-consistency decision, not leftover cruft.layout: aggregator-3panel— a Studio model-detail-page layout hint. Unrelated tovertical.yaml's ownlayout:vocabulary (see the application authoring reference's Friction points §3) despite the shared word.ui.primitives— the inline quick-translate box on Studio's Translate page. A separate, smaller mechanism from the maincapabilities.translateform.
application/packages.yaml
python: '3.12'
common:
- hutash-inference>=0.2.2
- transformers==4.46.3
- sentencepiece
- torch
- uvicorn==0.47.0
- fastapi==0.136.1
- python-multipart==0.0.29
variants:
gpu:
packages: []
indexes: [https://download.pytorch.org/whl/cpu]
cpu:
packages: []
indexes: [https://download.pytorch.org/whl/cpu]
system_packages: [build-essential, git, curl]
Both gpu and cpu point at the CPU wheel index — see §2 above for
why this is intentional given gpu: none, and why SelectVariant
still runs its full vendor-check logic regardless.
application/launch.yaml
command: uvicorn
args:
- --factory
- hutash_inference.server:create_app
- --host
- 127.0.0.1
- --port
- '{port}'
env:
HUTASH_MODEL_ID: opus-mt-en-hi
HUTASH_MODEL_DIR: '{model_dir}'
HF_HUB_CACHE: '{weights_dir}'
HF_HUB_OFFLINE: '1'
HUTASH_HF_REVISION: 75d7f7c9232b2891c7d65fe4ef635616c72be867
port: 8000
health_endpoint: /health
health_timeout: 120
{port}, {model_dir}, {weights_dir} are engine-filled template
placeholders, not literal strings — confirmed by the real value being
the same across every shipped package this session inspected.
resources/weights.yaml
sources:
- repo: Helsinki-NLP/opus-mt-en-hi
revision: 75d7f7c9232b2891c7d65fe4ef635616c72be867
allow_patterns:
- config.json
- generation_config.json
- pytorch_model.bin
- source.spm
- target.spm
- tokenizer_config.json
- vocab.json
download_size_gb: 0.29
allow_patterns excludes rust_model.ot/tf_model.h5 — the same
weights the HF repo also ships in two frameworks this package's
transformers.AutoModelForSeq2SeqLM never loads. Filtering them out at
download time, not after, is what keeps download_size_gb accurate and
the actual transfer small.
7. The .hutashm package structure
Confirmed from this session's own migration (hutash-daemon 44e0e81/
48bd9c5, hutash-app 9507436): identical zip layout to .hutash
— manifest.yaml at the root, application/, resources/. Nothing
about the internal structure changed; only the file's own extension,
and the local install-time folder suffix the engine chooses to match it
(<PackagesDir>/<id>.hutashm instead of <id>.hutash, decided by
packageDirExt(spec.Type) reading the manifest's own type: field —
see package-type-distinction.md in hutash-os for the full
mechanism).
opus-mt-en-hi.hutashm (zip)/
├── manifest.yaml
├── application/
│ ├── packages.yaml
│ └── launch.yaml
└── resources/
└── weights.yaml
No application/config/app.yaml (that's the application-type-only
file) and no inference.py shown here only because this package uses
the shared hutash-inference PyPI package (via launch.yaml's
command/args) rather than shipping its own — a pipeline that does
ship custom inference code puts it at application/inference.py
instead, per create-model-pipeline.md §4.