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.
check the engine version
curl -si http://localhost:47990/_ping | grep -i X-Hutash-Version

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.

bash
mkdir -p silero-vad.hutash/application mkdir -p silero-vad.hutash/resources cd silero-vad.hutash

Step 2

manifest.yaml

Identity at the top, then the ui: block Studio renders as the model's form.

manifest.yaml
cat > manifest.yaml <<'YAML' hutash_format: '1.0' id: silero-vad name: Silero VAD version: 1.0.0 naming: family: silero version: null variant: vad parameters: null quantization: null format: onnx type: model_pipeline gpu: none license: MIT capabilities: - id: stt modality: stt platforms: - windows - linux description: Voice activity detection — finds the speech in an audio file. metadata: weight_category: audio quality_score: 0.7 ui: model_id: silero-vad display_name: Silero VAD license: spdx: MIT url: https://github.com/snakers4/silero-vad/blob/master/LICENSE commercial_ok: true attribution_required: false attribution_text: null capabilities: stt: label: Detect Speech description: Find the spoken regions in an audio file primary: true endpoint: /stt status_message: Detecting speech… inputs: file: type: audio_file label: VAD audio file accept: - audio/* - wav - mp3 - flac required: true controls: threshold: type: slider label: VAD speech threshold description: Higher is stricter about what counts as speech min: 0.1 max: 0.9 step: 0.05 default: 0.5 min_speech_ms: type: number label: VAD minimum speech length description: Regions shorter than this are discarded default: 250 unit: ms advanced: true outputs: segments: type: json transcript: type: text hardware: min_vram_gb: 0 recommended_vram_gb: 0 supports_cpu: true total_install_gb: 1 improve: null api: health: /health gpu: false layout: aggregator-3panel YAML

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.

application/packages.yaml
cat > application/packages.yaml <<'YAML' python: '3.11' common: - hutash-inference>=0.2.2 - onnxruntime==1.20.1 - soundfile - numpy - uvicorn - fastapi - python-multipart system_packages: - build-essential - git - curl YAML

No variants: block here, because this model has no GPU path. If yours does, declare both and let the engine choose.

packages.yaml — with a GPU path
variants: gpu: packages: [torch==2.11.0] indexes: [https://download.pytorch.org/whl/cu128] cpu: packages: [torch==2.11.0+cpu] indexes: [https://download.pytorch.org/whl/cpu]

Step 4

application/launch.yaml

Get the exact commit first — never a branch.

resolve the commit
python -c "from huggingface_hub import HfApi; print(HfApi().model_info('onnx-community/silero-vad').sha)"

Then, with that value substituted for <COMMIT>:

application/launch.yaml
cat > application/launch.yaml <<'YAML' command: uvicorn args: - --factory - hutash_inference.server:create_app - --host - 127.0.0.1 - --port - '{port}' env: HUTASH_MODEL_ID: silero-vad HUTASH_MODEL_DIR: '{model_dir}' HF_HUB_CACHE: '{weights_dir}' HF_HUB_OFFLINE: '1' HUTASH_HF_REVISION: <COMMIT> port: 8000 health_endpoint: /health health_timeout: 120 YAML

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

resources/weights.yaml
cat > resources/weights.yaml <<'YAML' sources: - repo: onnx-community/silero-vad revision: <COMMIT> allow_patterns: - onnx/model.onnx - config.json download_size_gb: 0.01 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.

application/inference.py
cat > application/inference.py <<'PY' """Silero VAD inference. Implements the hutash_inference contract for stt.""" import io import numpy as np import soundfile as sf from hutash_inference import Inference, capability, resolve_local_weights_dir class SileroVADInference(Inference): """Voice activity detection. CPU-only, ONNX Runtime.""" def load(self) -> None: import onnxruntime as ort weights = resolve_local_weights_dir(self.model_id) self.session = ort.InferenceSession( f"{weights}/onnx/model.onnx", providers=["CPUExecutionProvider"], ) @capability("stt") def detect( self, file: bytes, threshold: float = 0.5, min_speech_ms: int = 250, ) -> dict: """Return the spoken regions of an audio file. Argument names match manifest.yaml's ui.capabilities.stt inputs and controls. The return keys match its outputs. """ audio, rate = sf.read(io.BytesIO(file), dtype="float32") if audio.ndim > 1: audio = audio.mean(axis=1) segments = self._run_vad(audio, rate, threshold, min_speech_ms) return { "segments": segments, "transcript": " ".join( f"[{s['start']:.2f}-{s['end']:.2f}]" for s in segments ), } def _run_vad(self, audio, rate, threshold, min_speech_ms): # Replace with the real windowed inference loop. The shape that # matters here is the RETURN: a list of {start, end} in seconds, # which is what the `segments` output declares. raise NotImplementedError("wire the ONNX session's windowed loop here") PY

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:

in hutash-studio
python dev/scripts/generate-model-files.py silero-vad python dev/scripts/generate-model-files.py silero-vad --verify npm run validate:schemas

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:

parse check
python -c "import yaml,sys; m=yaml.safe_load(open('manifest.yaml')); \ assert m.get('id'), 'no id'; \ caps=m['ui']['capabilities']; assert caps, 'no capabilities'; \ print('ok:', m['id'], list(caps))"

Step 8

Zip it.

Store paths with forward slashes and manifest.yaml at the archive root — not inside a wrapper folder.

bash
cd silero-vad.hutash zip -r ../silero-vad.hutash manifest.yaml application resources cd .. unzip -l silero-vad.hutash # manifest.yaml must be the first entry, at the root

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.

install
cp -r silero-vad.hutash "$HUTASH_PACKAGES_DIR/" curl -X POST http://localhost:47990/packages/silero-vad/install \ -H "Authorization: Bearer $HUTASH_API_TOKEN"

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 fieldDefaultWhat it does
package_url""Fetch the package from here instead of resolving it locally or from the catalogue.
package_typemodelmodel | 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:

status
curl -s http://localhost:47990/packages/silero-vad/status \ -H "Authorization: Bearer $HUTASH_API_TOKEN" # or stream it curl -N http://localhost:47990/packages/silero-vad/status/stream \ -H "Authorization: Bearer $HUTASH_API_TOKEN"

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.

list and start
curl -s http://localhost:47990/packages -H "Authorization: Bearer $HUTASH_API_TOKEN" \ | python -m json.tool | grep -A6 silero-vad curl -X POST http://localhost:47990/packages/silero-vad/start \ -H "Authorization: Bearer $HUTASH_API_TOKEN"

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:

verify
curl -s http://localhost:$PORT/health curl -s http://localhost:$PORT/manifest | python -m json.tool curl -X POST http://localhost:$PORT/stt \ -F "file=@sample.wav" \ -F "threshold=0.5" \ -F "min_speech_ms=250"

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

RouteWhat it is
GET /health200 once load() has returned. The contract is explicit: when this answers 200, every other endpoint is ready.
GET /manifestThe materialised ui: block — the same document the engine wrote at install.
GET /third-party-licensesThe attributions generated inside the install.
POST /offloadRelease the model from memory without killing the process.
POST /reloadBring 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.

publish
cp silero-vad.hutash <hutash-app>/pipelines/ cd <hutash-app> python scripts/build_index.py python scripts/build_index.py --check

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.