Generated banner for Dynamo Recipe Runner
Generated logo for Dynamo Recipe Runner

Dynamo Recipe Runner

NVIDIAOfficialCreated by Dan GilDistributed by NVIDIA

  • Skill
  • Workflow
  • Cloud and DevOps
  • Apache-2.0
  • Medium risk
  • Has scripts

From the author

Select, validate, patch, and deploy existing NVIDIA Dynamo Kubernetes recipes. Use for model/backend/GPU/deployment-mode recipe bring-up; use router-starter for router-only mode work and troubleshoot for broken deployments.
— the description by Dan Gil

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

Dynamo Recipe Runner streamlines the bring-up of model deployments on Kubernetes by leveraging existing recipe manifests. It provides a structured workflow to discover, validate, and patch configuration files before deploying them to a cluster. The skill ensures that model-cache jobs and deployment manifests are correctly configured, handles common blockers like storage classes and secret management, and performs a basic smoke test to verify the endpoint is operational.

What it does

  • Lists and filters available Dynamo recipes based on model, framework, and deployment mode.
  • Validates recipe manifests for common configuration errors before deployment.
  • Patches essential values such as storage classes, image tags, and resource requests.
  • Automates the deployment sequence including model-cache jobs and graph deployments.
  • Performs smoke tests to verify model availability and endpoint health.
  • Provides troubleshooting guidance for common Kubernetes deployment failures.

When to use it

  • When you need to deploy a specific model recipe onto a Kubernetes cluster.
  • When you need to validate that a recipe's configuration is compatible with your cluster environment.
  • When you need to quickly bring up a backend or GPU-accelerated deployment.
  • When you need to troubleshoot why a specific Dynamo deployment is failing to reach a healthy state.

Advantages

  • Reduces manual effort by automating the multi-step deployment process.
  • Ensures consistency by using standardized, existing recipe manifests.
  • Provides clear validation steps to catch configuration issues early.
  • Simplifies the integration of model-cache and deployment manifests.
  • Offers a reliable path from user intent to a functional model endpoint.

Usage examples

“List all available sglang recipes for the Dynamo framework.”
The agent will execute the recipe tool to display a table of sglang-compatible recipes.
“Validate the nemotron-3-super-fp8 recipe for aggregation mode.”
The agent will run the validation script on the specified recipe path and report any missing requirements or blockers.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/nvidia/dynamo-recipe-runner/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip dynamo-recipe-runner.zip -d .agents/skills

Documentation by Dan Gil

Dynamo Recipe Runner

<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: CC-BY-4.0 -->

Purpose

Get from user intent to a working Dynamo recipe endpoint with minimal back and forth. Do not create new guide content. Operate on the existing recipes/ tree, patch the smallest necessary set of manifests, deploy when the user has cluster access, and prove success with an OpenAI-compatible smoke request.

Prerequisites

  • Python 3.10+ on the operator machine.

  • kubectl configured with a working cluster context.

  • Cluster has a default storage class for model-cache PVCs.

  • Hugging Face token stored in a Kubernetes secret named hf-token-secret (or equivalent) in the target namespace.

  • Read access to the recipes/ tree in the ai-dynamo/dynamo repository.

Required Inputs

Collect or infer these before changing manifests:

  • recipe target: model, framework (vllm, sglang, trtllm, tokenspeed), deployment mode, and GPU type/count

  • Kubernetes context and namespace

  • Hugging Face secret name, usually hf-token-secret

  • storage class for model cache PVCs

  • runtime image tag if the recipe uses a placeholder or stale test image

  • whether to run commands or only produce exact commands

If a required value is missing and cannot be inferred from the selected recipe, ask for only that value.

Instructions

1. Preflight

Run read-only checks first:

git status --short
python3 scripts/recipe_tool.py list --format table
kubectl config current-context
kubectl get storageclass
kubectl get nodes -o wide
kubectl get namespace "${NAMESPACE}"
kubectl get secret hf-token-secret -n "${NAMESPACE}"

If kubectl is unavailable or the cluster is unreachable, continue by selecting and validating the recipe, then return exact commands instead of pretending the deployment ran.

2. Select The Recipe

Use the recipe matrix from recipes/README.md and the scanner:

python3 scripts/recipe_tool.py list \
  --query qwen --framework vllm --mode disagg --format table

Prefer an exact existing recipe. Do not invent new manifests unless the user explicitly asks to author a new recipe.

3. Inspect And Validate

Read the selected recipe README, model-cache manifests, deploy.yaml, and perf.yaml if present. Then run:

python3 scripts/recipe_tool.py validate \
  recipes/<model>/<framework>/<mode>

Resolve reported blockers before applying manifests: storage class, model cache PVC, image tag, HF token secret, GPU count, frontend service name, and router mode.

4. Patch Minimal Values

Patch only recipe-specific values needed for this run. Do not reformat whole YAML files. Common patches:

  • storageClassName

  • image repository/tag

  • model path or model cache mount path

  • GPU resource requests/limits

  • frontend DYN_ROUTER_MODE

  • namespace only when a manifest hardcodes it

Never write Hugging Face tokens into files or logs. Use Kubernetes secrets.

5. Deploy

Follow the selected recipe README when it differs from the default sequence. The default sequence is:

kubectl apply -f recipes/<model>/model-cache/ -n "${NAMESPACE}"
kubectl wait --for=condition=Complete job/model-download -n "${NAMESPACE}" --timeout=6000s
kubectl apply -f recipes/<model>/<framework>/<mode>/deploy.yaml -n "${NAMESPACE}"
kubectl get dynamographdeployment -n "${NAMESPACE}"
kubectl get pods -n "${NAMESPACE}" -o wide

Wait for the frontend and workers to be ready before testing.

6. Smoke Test

Port-forward the frontend service, then verify /v1/models and one chat completion:

kubectl port-forward svc/<deployment-name>-frontend 8000:8000 -n "${NAMESPACE}"
curl http://127.0.0.1:8000/v1/models

If dynamo-router-starter is also installed, prefer its scripts/check_router_health.py for the full OpenAI-compatible smoke test. If this fails, switch to dynamo-troubleshoot.

Available Scripts

ScriptPurposeArguments
scripts/recipe_tool.py listEnumerate available recipes, optionally filtered--query, --framework, --mode, --format
scripts/recipe_tool.py validateValidate a recipe directory before applypositional recipe path

Invoke via the agentskills.io run_script() protocol:

run_script("scripts/recipe_tool.py", args=["list", "--framework", "sglang", "--format", "table"])
run_script("scripts/recipe_tool.py", args=["validate", "recipes/nemotron-3-super-fp8/sglang/agg"])

Examples

List sglang recipes that fit a single 8xB200 node:

python3 scripts/recipe_tool.py list --framework sglang --format table

Validate a specific recipe and resolve blockers before applying:

python3 scripts/recipe_tool.py validate recipes/nemotron-3-super-fp8/sglang/agg

Equivalent through the agent protocol:

run_script("scripts/recipe_tool.py", args=["validate", "recipes/nemotron-3-super-fp8/sglang/agg"])

Output Contract

Return:

  • selected recipe path and why it was selected

  • exact values patched

  • commands run or commands to run

  • endpoint and smoke-test result

  • unresolved blockers, if any

  • next troubleshooting step when deployment does not become healthy

Limitations

  • Operates on the existing recipes/ tree only. Does not author new manifests.

  • Cluster-mutating apply steps require kubectl permission to the target namespace.

  • Smoke-test depth is intentionally minimal; for full router/endpoint coverage use dynamo-router-starter.

  • Multi-node disagg transport correctness is out of scope; use dynamo-interconnect-check after deploy.

Troubleshooting

SymptomLikely causeNext step
kubectl cluster unreachableContext not set or VPN downReturn exact commands instead of running them; resume when cluster is reachable
validate reports missing storage classCluster has no default StorageClassPatch storageClassName on the model-cache manifest before applying
Model-cache job stuck PendingPVC unbound or HF secret missingInspect PVC events; create or rename the HF secret to match the recipe
Worker pods ImagePullBackOffStale image tag or missing pull secretPatch the image tag; verify image pull secret in the namespace
/v1/models 4xx/5xx after deployFrontend not ready or wrong service portWait for pods Ready; re-run port-forward; switch to dynamo-troubleshoot if it persists

Benchmark

See BENCHMARK.md for the NVCARPS-EVAL performance report (auto-generated by the NVSkills CI pipeline). To refresh, re-run /nvskills-ci on an upstream PR touching this skill.

References

  • Read references/k8s-recipe-workflow.md for command templates and readiness checks.

  • Use scripts/recipe_tool.py for recipe discovery and lightweight validation.

Files

scripts/recipe_tool.py

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Discover and lightly validate Dynamo recipes."""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable

FRAMEWORKS = {"vllm", "sglang", "trtllm", "tokenspeed"}
PLACEHOLDER_RE = re.compile(r"(<[^>]+>|your-|change-me|changeme|my-tag|TODO)", re.I)
# Recipes declare GPUs either as the DynamoGraphDeployment shorthand
# (`limits.gpu: "4"`) or the standard Kubernetes `nvidia.com/gpu: 4`; match both.
GPU_RE = re.compile(r"(?:nvidia\.com/gpu|(?<![\w./-])gpu):\s*[\"']?(\d+)")


@dataclass
class Recipe:
    model: str
    framework: str
    mode: str
    path: str
    deploy_yaml: str
    perf_yaml: str | None
    model_cache_dir: str | None
    gpu_count_hint: int | None


def repo_root(start: Path) -> Path:
    for path in [start, *start.parents]:
        if (path / "recipes").is_dir() and (path / ".git").exists():
            return path
    raise SystemExit("Could not find Dynamo repo root from current directory")


def read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return path.read_text(errors="replace")


def gpu_values_in_yaml_blocks(text: str, block_name: str) -> list[int]:
    values: list[int] = []
    in_block = False
    block_indent = 0
    for line in text.splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        indent = len(line) - len(line.lstrip())
        if stripped == f"{block_name}:":
            in_block = True
            block_indent = indent
            continue
        if in_block and indent <= block_indent:
            in_block = False
        if in_block:
            match = GPU_RE.search(line)
            if match:
                values.append(int(match.group(1)))
    return values


def gpu_count_hint(text: str) -> int | None:
    limits = gpu_values_in_yaml_blocks(text, "limits")
    if limits:
        return sum(limits)
    requests = gpu_values_in_yaml_blocks(text, "requests")
    if requests:
        return sum(requests)
    values = [int(match) for match in GPU_RE.findall(text)]
    return max(values) if values else None


def discover(root: Path) -> list[Recipe]:
    recipes_dir = root / "recipes"
    recipes: list[Recipe] = []
    for deploy in sorted(recipes_dir.rglob("deploy.yaml")):
        rel = deploy.relative_to(recipes_dir)
        parts = rel.parts
        framework_index = next(
            (i for i, part in enumerate(parts) if part in FRAMEWORKS), None
        )
        if framework_index is None:
            model = parts[0]
            framework = "unknown"
            mode_parts = parts[1:-1]
        else:
            model = "/".join(parts[:framework_index])
            framework = parts[framework_index]
            mode_parts = parts[framework_index + 1 : -1]
        mode = "/".join(mode_parts) if mode_parts else "unknown"
        recipe_dir = deploy.parent
        model_cache = recipes_dir / model / "model-cache"
        perf = recipe_dir / "perf.yaml"
        text = read_text(deploy)
        recipes.append(
            Recipe(
                model=model,
                framework=framework,
                mode=mode,
                path=str(recipe_dir.relative_to(root)),
                deploy_yaml=str(deploy.relative_to(root)),
                perf_yaml=str(perf.relative_to(root)) if perf.exists() else None,
                model_cache_dir=str(model_cache.relative_to(root))
                if model_cache.exists()
                else None,
                gpu_count_hint=gpu_count_hint(text),
            )
        )
    return recipes


def match_recipes(
    recipes: Iterable[Recipe],
    query: str | None,
    framework: str | None,
    mode: str | None,
) -> list[Recipe]:
    out = []
    for recipe in recipes:
        haystack = " ".join(
            [recipe.model, recipe.framework, recipe.mode, recipe.path]
        ).lower()
        if query and query.lower() not in haystack:
            continue
        if framework and recipe.framework != framework:
            continue
        if mode and mode.lower() not in recipe.mode.lower():
            continue
        out.append(recipe)
    return out


def print_table(recipes: list[Recipe]) -> None:
    headers = ["model", "framework", "mode", "gpus", "perf", "path"]
    rows = [
        [
            recipe.model,
            recipe.framework,
            recipe.mode,
            "" if recipe.gpu_count_hint is None else str(recipe.gpu_count_hint),
            "yes" if recipe.perf_yaml else "no",
            recipe.path,
        ]
        for recipe in recipes
    ]
    widths = [
        max(len(str(row[i])) for row in [headers, *rows]) if rows else len(headers[i])
        for i in range(len(headers))
    ]
    print("  ".join(headers[i].ljust(widths[i]) for i in range(len(headers))))
    print("  ".join("-" * widths[i] for i in range(len(headers))))
    for row in rows:
        print("  ".join(str(row[i]).ljust(widths[i]) for i in range(len(headers))))


def line_matches(path: Path, patterns: list[re.Pattern[str]]) -> list[str]:
    hits: list[str] = []
    if not path.exists() or not path.is_file():
        return hits
    for lineno, line in enumerate(read_text(path).splitlines(), start=1):
        if any(pattern.search(line) for pattern in patterns):
            hits.append(f"{path}:{lineno}: {line.strip()}")
    return hits


def metadata_names(path: Path) -> list[str]:
    names = []
    for match in re.finditer(
        r"(?m)^metadata:\n(?:  .*\n)*?  name:\s*([A-Za-z0-9_.-]+)", read_text(path)
    ):
        names.append(match.group(1))
    return names


def model_cache_dir_for(root: Path, recipe_dir: Path) -> Path | None:
    """Locate the model-level model-cache dir that sits beside a recipe.

    Recipes live at ``recipes/<model>/<framework>/<mode>`` while the
    model-cache manifests live at the sibling ``recipes/<model>/model-cache``,
    so validating only the recipe subtree would miss them.
    """
    recipes_dir = root / "recipes"
    try:
        rel = recipe_dir.relative_to(recipes_dir)
    except ValueError:
        return None
    if not rel.parts:
        return None
    candidate = recipes_dir / rel.parts[0] / "model-cache"
    return candidate if candidate.is_dir() else None


def validate(root: Path, target: Path) -> dict[str, object]:
    target = target if target.is_absolute() else root / target
    if target.is_file():
        files = [target]
        recipe_dir = target.parent
    else:
        recipe_dir = target
        files = sorted(target.rglob("*.yaml")) + sorted(target.rglob("*.yml"))

    if not files:
        raise SystemExit(f"No YAML files found under {target}")

    # Pull in the sibling model-level model-cache manifests so storage-class
    # and model-download blockers are not silently skipped.
    if not any("model-cache" in path.parts for path in files):
        mc_dir = model_cache_dir_for(root, recipe_dir)
        if mc_dir:
            files = (
                files + sorted(mc_dir.rglob("*.yaml")) + sorted(mc_dir.rglob("*.yml"))
            )

    deploy_files = [path for path in files if path.name == "deploy.yaml"]
    perf_files = [path for path in files if path.name == "perf.yaml"]
    model_cache_files = [path for path in files if "model-cache" in path.parts]

    warnings: list[str] = []
    blockers: list[str] = []

    for path in files:
        rel = path.relative_to(root) if path.is_relative_to(root) else path
        text = read_text(path)
        if PLACEHOLDER_RE.search(text):
            warnings.append(f"{rel}: contains placeholder-looking values")
        if path.name == "deploy.yaml" and "image:" not in text:
            warnings.append(f"{rel}: no image field found")
        if "HF_TOKEN" in text or "HUGGING_FACE" in text or "HUGGINGFACE" in text:
            if "hf-token-secret" not in text and "secretKeyRef" not in text:
                warnings.append(
                    f"{rel}: references Hugging Face env vars without an obvious secret"
                )
        if "storageClassName" in text and PLACEHOLDER_RE.search(text):
            blockers.append(f"{rel}: storageClassName appears to be a placeholder")

    if not deploy_files:
        blockers.append("No deploy.yaml found for this target")
    if not model_cache_files and (
        recipe_dir.parts and "model-cache" not in recipe_dir.parts
    ):
        warnings.append(
            "No model-cache YAML found under target; check the model-level model-cache directory"
        )

    deployment_names = []
    for deploy in deploy_files:
        deployment_names.extend(metadata_names(deploy))

    def rel_hits(pattern: re.Pattern[str]) -> list[str]:
        return [
            hit.replace(str(root) + "/", "")
            for path in files
            for hit in line_matches(path, [pattern])
        ]

    return {
        "target": str(
            target.relative_to(root) if target.is_relative_to(root) else target
        ),
        "deploy_files": [str(path.relative_to(root)) for path in deploy_files],
        "perf_files": [str(path.relative_to(root)) for path in perf_files],
        "model_cache_files": [
            str(path.relative_to(root)) for path in model_cache_files
        ],
        "deployment_names": deployment_names,
        "gpu_count_hint": sum(
            value
            for value in (gpu_count_hint(read_text(path)) for path in deploy_files)
            if value
        )
        or None,
        "interesting_lines": {
            "storageClassName": rel_hits(re.compile(r"storageClassName")),
            "images": rel_hits(re.compile(r"^\s*image:\s*")),
            "hf_secret": rel_hits(re.compile(r"hf-token-secret|HF_TOKEN|HUGGING")),
            "router": rel_hits(re.compile(r"DYN_ROUTER|router-mode|router_mode")),
        },
        "warnings": warnings,
        "blockers": blockers,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)

    list_parser = sub.add_parser("list", help="List recipe deployment candidates")
    list_parser.add_argument("--query")
    list_parser.add_argument("--framework")
    list_parser.add_argument("--mode")
    list_parser.add_argument("--format", choices=["json", "table"], default="json")

    validate_parser = sub.add_parser("validate", help="Validate a recipe path")
    validate_parser.add_argument("target", help="Recipe directory or YAML file")

    args = parser.parse_args()
    root = repo_root(Path.cwd().resolve())

    if args.command == "list":
        recipes = match_recipes(discover(root), args.query, args.framework, args.mode)
        if args.format == "table":
            print_table(recipes)
        else:
            print(json.dumps([asdict(recipe) for recipe in recipes], indent=2))
        return 0

    if args.command == "validate":
        print(json.dumps(validate(root, Path(args.target)), indent=2))
        return 0

    return 1


if __name__ == "__main__":
    sys.exit(main())

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs network access, Needs a command-line tool, Needs Python, Needs Node.js

Security

What our scanner noticed· Medium risk

  • Large base64-encoded blobs
  • Instructions that use the network
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Dan Gil
Publisher
NVIDIA (Official)
Distributed by
NVIDIA
Source
NVIDIA/skills · skills/dynamo-recipe-runner · commit fce3cca
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE-APACHE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Dan Gil or NVIDIA.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download dynamo-recipe-runner.zip