Package a model from scratch
This builds a type: model_pipeline package, installs it with the engine, and runs an inference request against it. The example is Silero VAD — small, MIT-licensed, CPU-only, downloads in seconds. Swap the repository and the code and the shape is unchanged.
Two caveats firstThere is no first-party script that builds a .hutash from scratch. build_package.py exists, but its own docstring records why it is not that script: the three runtime and resource files live only inside the zip, with no loose copy anywhere to assemble from — so it takes an existing package as its base and replaces only the ui: key. The first package of a new model is built by hand with zip; every update after that goes through build_package.py. And the older contributing guide describes a Docker-based path — docker build, docker push, a catalogue.json entry. That path is superseded: pipelines now install into a Python virtual environment the engine builds from packages.yaml, and catalogue.json was replaced by index.json. The steps below follow what the engine's parser and the shipped packages actually do.
Step 0
What you need.
- A running engine, whose version matches the repository you are testing against.
- zip and python on your path.
- A model that exists on HuggingFace with a pinned commit you can name.
Step 1
Make the working directory.
The directory name ends in .hutash on purpose: the engine reads both a zipped package and an unzipped folder of the same shape, so you can install straight from this directory while developing.
Step 2
manifest.yaml
Identity at the top, then the ui: block Studio renders as the model's form.
Three things that will bite if you skip them. accept carries both audio/* and at least one extension — without the glob the drop zone fails at drag time, because browsers hide filenames then. Every label is prefixed VAD, because labels must be unique across every model's every capability and "Threshold" alone would collide. And the capability key is stt, one of the seven the linter allows; a new modality is a coordinated change across four files, not something to slip past.
Step 3
application/packages.yaml
hutash-inference is the shared server that turns your inference.py into an HTTP service; every shipped pipeline lists it.
No variants: block here, because this model has no GPU path. If yours does, declare both and let the engine choose.
Step 4
application/launch.yaml
Get the exact commit first — never a branch.
Then, with that value substituted for <COMMIT>:
Do not write an entry point of your own. {port}, {model_dir} and {weights_dir} are substituted by the engine, and port: 8000 is a template anchor, not the port served on.
Step 5
resources/weights.yaml
revision must be the same commit as HUTASH_HF_REVISION in launch.yaml. The weights resolver looks for that exact snapshot and raises rather than falling back to whatever snapshot happens to be present — a drifted mount fails loudly instead of loading the wrong weights. Set allow_patterns or you will download the repository's demo audio and documentation too.
Step 6
application/inference.py
The class inherits Inference and marks each capability handler with @capability. The shared server reads manifest.json, finds the decorated methods, checks they match the manifest, and wires them to HTTP endpoints. Your parameter names must match the manifest's inputs and controls keys.
The load() / @capability split is load-bearing: load() runs once when the process starts and the health endpoint stays down until it returns, which is what lets the engine tell "still loading a large model" apart from "crashed". Your code needs no Low-VRAM handling — the engine sets a budget and a framework name in the environment, and the shared server translates that into whatever knob your framework actually understands.
Step 7
Check the schema.
The linter runs against the authoring source, not against the package. If you are authoring there:
Any ⚠ PRIMITIVE WARNING means the manifest references a control, input, layout or capability id this build cannot render. Fix the YAML before going further — dev/primitives.json lists what the current build supports.
If you authored the ui: block directly in manifest.yaml, as above, check at minimum that it parses and that the rules hold:
Step 8
Zip it.
Store paths with forward slashes and manifest.yaml at the archive root — not inside a wrapper folder.
Step 9
Install it.
The engine finds a local package by convention, not by a path you hand it. Put the unzipped folder — named <id>.hutash, with manifest.yaml at its root — into the engine's packages directory (HUTASH_PACKAGES_DIR, defaulting to <base>/packages), then install with an empty body.
The resolver checks <packagesDir>/<id>.hutash/manifest.yaml first, then falls back to scanning every installed folder for one whose manifest id matches — so a folder named differently from its id still resolves. With no local package present, the request needs a package_url or a catalogue entry to resolve the id, and 404s with "no local package, package_url, or catalogue entry" when it has none. The bearer token is the shared session token in hutashd.json under api_token — the engine, the OS shell and every app read the same file.
| Request field | Default | What it does |
|---|---|---|
| package_url | "" | Fetch the package from here instead of resolving it locally or from the catalogue. |
| package_type | model | model | app. |
| runtime | "" | Override: venv | container. Anything else is a 400. |
| version | "" | A specific version tag; empty resolves to latest. |
The call returns 202 with an initial status snapshot; the install runs asynchronously through fetch → installing (packages and assets in parallel) → starting → health check. Follow it:
Re-posting an id whose steps partly failed retries only the failed step. The install builds a virtual environment from packages.yaml, downloads the weights named in weights.yaml, and materialises the ui: block as application/manifest.json next to your inference.py. The engine refuses an install on exactly one ground: physical impossibility — the model cannot fit in this host's VRAM and RAM combined. Everything else (partial-load performance, framework support, warnings) is the application's call, not the engine's.
Step 10
Confirm it is there, and start it.
GET /packages reports the assigned port as host_port, with port kept as a back-compatible alias. Read the port from there, never from a literal — every port comes from the engine's pool (49200–65535) and moves between runs.
POST /packages/{id}/start returns 202 immediately; the start and the health check run in the background. It refuses up front rather than returning a misleading "starting" in two cases: a 404 for an id with no install record and no environment on disk, and a 400 — "Requires GPU. Enable in Settings." — for a gpu: required model with the GPU switch off. Asking for a start by hand also clears any terminal load failure, on the reasoning that whatever the model was short of may since have been fixed.
Step 11
Run something.
With PORT set to what the engine reported:
The last call returns the dict your detect() returned — segments and transcript, exactly the keys the manifest's outputs declared. That is the end state: a package you wrote by hand, installed by the engine, serving inference on a port it assigned, against a form Studio can render without being told anything about your model.
What a finished package answers
| Route | What it is |
|---|---|
| GET /health | 200 once load() has returned. The contract is explicit: when this answers 200, every other endpoint is ready. |
| GET /manifest | The materialised ui: block — the same document the engine wrote at install. |
| GET /third-party-licenses | The attributions generated inside the install. |
| POST /offload | Release the model from memory without killing the process. |
| POST /reload | Bring it back. |
| POST <endpoint> | One per capability, at the path its endpoint: names — defaulting to /{capability_id} when it declares none. |
Step 12
Publish it.
Copy the package into the catalogue repository and regenerate the index — never hand-edit index.json.
Every field the index needs is read out of the manifest and weights.yaml you already wrote. From here on, an update is a version bump and a new zip: edit the authoring source, regenerate manifest.json, and run scripts/build_package.py silero-vad --manifest-json <path> --version 1.0.1, which replaces only the ui: block and leaves the identity, runtime and resource layers untouched. There is no separate update path for anything — pipelines, applications and Studio itself all move through a new .hutash version in the catalogue.