> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuneplane.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# JobSpec

> The tuneplane/v1 payload tp submit builds and the server admits, for reading a rejection or writing your own tooling.

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp submit my-grpo --profile h200:8
cat .tuneplane/jobspec.json
```

You do not write a JobSpec by hand. `tp submit` builds one into the job package at
`.tuneplane/jobspec.json`. Read this page when you are debugging a refused submission, writing a
tool that submits, or working through MCP.

`apiVersion` is `tuneplane/v1` and `kind` is `TrainingJob`. Any other value is refused on the
client and again on the server, before anything is scheduled.

## Shape

```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
{
  "apiVersion": "tuneplane/v1",
  "kind": "TrainingJob",
  "metadata": {
    "name": "my-grpo",
    "project": "my-lab",
    "owner": "",
    "display_name": "",
    "labels": {}
  },
  "spec": {
    "recipe": {
      "name": "nemo-rl/grpo",
      "version": "0.7.0",
      "digest": "sha256:…",
      "plugins": []
    },
    "source": { "exp": "experiments/my-grpo", "entrypoint": "" },
    "framework": {
      "kind": "nemo-rl",
      "version": "0.7.0",
      "runtime_id": "nemo-rl-0.7.0",
      "image": "",
      "observability_url": ""
    },
    "resources": {
      "pools": [{ "name": "train", "series": "h200", "nodes": 1, "gpus_per_node": 8 }],
      "roles": { "actor": "train", "rollout": "train" }
    },
    "model": {
      "base": "Qwen/Qwen2.5-1.5B",
      "peft": null,
      "init_from": { "run_id": "run-4f2a91", "kind": "checkpoint", "step": 500 }
    },
    "data": {
      "train": { "dataset": "alice/gsm8k-zh@v2", "split": "", "path": "train.parquet" },
      "validation": null,
      "volumes": ["acme/support-transcripts"],
      "output_dataset": "",
      "environment": "",
      "quality": { "max_duplicate_ratio": null, "overlap_with": "", "max_overlap_ratio": null }
    },
    "hyperparams": { "policy.optimizer.kwargs.lr": 1e-6 },
    "lifecycle": { "on_success": ["export"] },
    "evaluation": {
      "suites": ["gsm8k"],
      "runner": "",
      "baseline": "",
      "limit": null,
      "gates": [{ "metric": "exact_match", "min": 0.4, "max": null, "max_regression": 0.02 }]
    },
    "plugins": [{ "id": "alice/opsd-patch", "version": "1.2.0", "digest": "sha256:…" }]
  },
  "provenance": {
    "core_version": "0.3.2",
    "git_commit": "…",
    "git_dirty": false,
    "config_sha": "…",
    "model_name": "Qwen/Qwen2.5-1.5B"
  }
}
```

## Field rules

Every rule here exists because a submission was refused for it.

| Field | Rule |
| - | - |
| `metadata.owner` | Whatever the client sends is ignored. The server stamps the authenticated account |
| `metadata.project` | Comes from `name` in `tuneplane.yaml`. Do not pass `--project` |
| `spec.source.entrypoint` | Must be empty. The recipe owns the entrypoint; use `custom/custom` to run your own command |
| `spec.framework.image` | The `--image` override. Required for custom. The registry host must be on the deployment's allowlist |
| `spec.framework.observability_url` | Required when the recipe declares `external` observability, forbidden when it declares `platform` |
| `spec.resources.pools` | Non-empty. GPU count is `nodes × gpus_per_node` per pool, summed across pools for the quota check |
| `spec.resources.roles` | Maps a role name to a pool name. Every pool a role names has to exist |
| `spec.model.init_from` | `{run_id, kind, step}`. On the CLI this is `run/<run_id>/checkpoint[@step=N]` |
| `spec.data.*.dataset` | `[<hub>:]<owner>/<name>[@version]`. Unprefixed means this platform |
| `spec.data.volumes` | A list of `<owner>/<name>`, with **no** version suffix. Mounted read-only at `$VOLUMES_DIR/<leaf>`. Two volumes whose leaf names collide are refused, and so is a hard-coded path — that would skip the access check |
| `spec.data.output_dataset` | `<owner>/<name>@<version>` the run publishes from `$TUNEPLANE_DATASET_OUT_DIR` |
| `spec.data.environment` | `<owner>/<name>@<version>` of an agent environment |
| `spec.evaluation.gates[]` | `metric` plus any of `min`, `max`, `max_regression` |
| `spec.plugins[].id` | `<owner>/<name>`, with digests starting `sha256:`. Two plugins that would unpack to the same leaf directory cannot be combined |

## The handshake

Admission compares four things against the server's catalogue: the recipe name, its version, its
digest, and `framework.runtime_id`. Any mismatch is refused.

That is what `tp recipe status` reports and `tp recipe upgrade` fixes. `provenance.core_version`
must also satisfy the `requires.core` range on the lock, so an old CLI submitting against a newer
catalogue is refused with both versions named.

## Next

[Job environment variables](https://docs.tuneplane.ai/en/reference/job-env.md) for what the container receives ·
[Submitting a job](https://docs.tuneplane.ai/en/guides/submit.md) · [Errors](https://docs.tuneplane.ai/en/api-reference/errors.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.