> ## 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.

# Agent environments

> Register an agent execution environment and its associated taskset, verifier and connection settings.

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp env push ./my-env --name calculator-tasks --version 1.0.0
tp env ls
tp env show alice/calculator-tasks@1.0.0
```

An Agent Environment is the execution context in which an agent acts and receives
observations. Its taskset supplies tasks and its verifier judges completion.
The current publication format bundles a taskset with an environment manifest,
owned and versioned as `<owner>/<name>@<version>`. The manifest declares how to
connect or run the environment; the bundle itself is not a running sandbox.

## Layout

```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
my-env/
├── manifest.json     # what the environment declares
└── tasks.jsonl       # one task per line
```

Everything under the directory is uploaded. Versions are immutable. Only `deployment: local`
requires `tasks.jsonl`; remote services and hosted images own their task data.

## tasks.jsonl

One JSON object per line:

```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
{"prompt": "What is 17% of 340?", "reference": "57.8", "split": "train"}
{"prompt": "Convert 45 degrees Celsius to Fahrenheit.", "reference": "113", "split": "eval"}
```

**split** `train | eval` (default: train)

Which half of the taskset this row belongs to. Declared per row.

The platform sets which half a job sees from the operation itself, so a training job cannot be
handed the held-out tasks and an evaluation cannot ask for the training ones. An evaluation against
a split the environment never declared is refused rather than quietly served the training tasks —
that is the one failure this field exists to prevent, and it is the one that looks like a good
score.

> [!WARNING]
>
> A taskset is never handed out. No route returns a copy, and there is no export. What a business
> considers hard is often more revealing than its training data, so a job *references* an
> environment and receives task content only inside the approved runtime.

## manifest.json

```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
{
  "schema": "tuneplane/environment/v1",
  "protocol": "openenv",
  "deployment": "local",
  "verifier": { "kind": "rubric", "ref": "alice/arithmetic-correctness" },
  "tools": [
    {
      "name": "calculator",
      "endpoint": "/tools/calculator",
      "schema": {
        "type": "object",
        "properties": { "expression": { "type": "string" } },
        "required": ["expression"]
      }
    }
  ],
  "sandbox": { "required": false },
  "description": "Arithmetic word problems with a calculator tool."
}
```

**schema** `string` *required*

Always `tuneplane/environment/v1`.

**protocol** `nemo-gym | openenv` *required*

The interaction contract. OpenEnv uses reset / step / state; NeMo Gym uses trajectory sessions.

**deployment** `local | remote | container` *required*

Who starts the environment implementation. `local` runs inside the job, `remote` connects to
an existing service, and `container` asks the executor to host an image. NeMo Gym currently
supports only `local`. Schema v1 and the former compound protocol names are rejected.

**verifier** `object` *required*

`{"kind": "rubric|plugin|endpoint", "ref": "..."}` — what decides whether a task was completed.
See [verifiers](https://docs.tuneplane.ai/en/extend/verifiers.md).

**tools** `array`

What the agent may call: a name, an endpoint, and a JSON Schema for the arguments.

**harness** `object`

`{"plugin": "<owner>/<name>", "version": "...", "entrypoint": "module:ClassName", "digest": "..."}`.
Required for `nemo-gym`. The code belongs to a `capability: environment` plugin; the platform writes at
most a two-line shim that imports it.

**sandbox.required** `bool` (default: false)

True when the harness runs model-generated code. A job that needs one, on a deployment with no
sandbox provider configured, is **refused** — it does not fall back to running the code in the
training container.

**domain** `string` (default: other)

NeMo Gym's metrics-grouping category. Required and validated for `nemo-gym`, meaningless elsewhere.
One of `math`, `coding`, `agent`, `knowledge`, `instruction_following`, `long_context`, `safety`,
`games`, `translation`, `e2e`, `rlhf`, `other`.

**image** `string`

The OCI image for an `openenv` environment. Resolved as a pinned runtime artifact like every other
image; never pulled ad hoc.

**endpoint** `url`

Only valid with `deployment: remote`. Use either a fixed endpoint, a Fleet, or registered runners.
Do not specify both endpoint and Fleet.

## Protocol and deployment

Two interaction contracts and three deployment choices describe four supported combinations.
A sandbox provider such as Docker or E2B is a separate choice; it is not a protocol value.

**OpenEnv + local**

Turn-level. The platform runs the environment server from the taskset it holds, and the trainer
drives the episode itself through openenv-core's client: `reset`, `step`, `state`.

Used by TRL, SkyRL, Unsloth and Axolotl. No harness needed — the trainer already owns the loop.

**NeMo Gym + local**

Trajectory-level (`/seed_session` + `/verify`). NeMo Gym launches its own servers from a config
entrypoint, so there is nothing for the platform to serve. Instead the platform **materialises**
the environment: it writes the authorised version into the job as the files NeMo Gym expects to
find.

Somebody has to run the trajectory loop, and the platform does not write it, so a
`harness.plugin` is required — plus `harness.entrypoint` as `module:ClassName`, naming the
`SimpleResourcesServer` subclass Gym imports.

**OpenEnv + remote**

A service that already runs elsewhere. The platform never starts it and never proxies it; it
decides whether the job may reach that host, resolves where it answers, and hands that address
to the trainer. Costs an egress decision and nothing more.

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp env runner register http://envbox-3.corp:8080 \
  --name shared-sim --version 1.0.0 \
  --description "Shared simulator, ops-managed"
```

That verifies the running service and registers it without needing an administrator. By default
the environment scores its own turns — OpenEnv's `Observation` carries the reward — and the
manifest says so. Pass `--rubric <owner>/<name>` to move that judgement to the platform judge
instead.

**Where it answers is separate from what it is.** A fixed address is sealed into an immutable
version, so moving the box means publishing a new version. The two alternatives do not:

* `tp env runner serve <env> --at <url>` — register this machine as a runner and heartbeat.
  Several machines may serve one environment; a job goes to whichever answered most recently.
* `--fleet <id>` at registration — the platform picks a machine from that Fleet.

Registered runners are listed on the environment in the console, and with
`tp env runner ls <env>`. Both show every runner whatever state it is in, and mark each one
`answering` or not: **"registered and not answering" and "nobody ever set one up" are different
things to debug**, and a list that hid the first would send you to build what already exists.
A runner can be taken out of service without being forgotten, from either surface.

See [Reaching a remote environment](#reaching-a-remote-environment) for what to do when the
cluster cannot route to the host.

**OpenEnv + container**

An environment that ships as a container image — what the wider OpenEnv ecosystem publishes.
The executor starts it next to the trainer, in the same network namespace, and hands the
trainer its address; from the job's side it is indistinguishable from a service somebody else
runs.

Needs no network arrangement at all, which is what makes it the route to prefer when the
environment can be containerised. On Kubernetes it is a sidecar in the Ray head Pod, so it
answers on **the Pod's** loopback — two jobs on one node cannot collide over the port, and
neither container can reach the node's own services.

The environment container gets no GPU and no volume mounts. It scores turns; it is not the
work, and every resource it does not receive is one it cannot reach past its own execution.

**Say which port the image listens on** unless it listens on `8900`:

```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
{ "protocol": "openenv", "deployment": "container", "image": "ghcr.io/acme/env:1", "image_port": 8000 }
```

The platform did not build this image and cannot influence what it binds, so the port is
something the image states rather than something the platform assigns. An image serving
somewhere the job does not dial answers nothing, and the run fails inside the trainer on a
connection error that names neither the environment nor the port.

> [!NOTE]
>
> Kuberay fleets only, for now. `node` can host one and do not yet — on a plain
> container runtime it needs a per-job network, because the alternatives are worse: host
> networking hands third-party code the node's loopback, and a published host port collides
> between two jobs running the same image. Slurm will not: materialising an OCI image on
> somebody else's login node is not this platform's to do.

## Reaching a remote environment

An `openenv` (`deployment: remote`) environment only works if the **training job** can open a connection to it.
The control plane resolves *where* it answers and hands that address over; it never carries the
episode itself ([ADR-0025](https://github.com/tuneplane/tuneplane/blob/main/docs/adr/0025-the-control-plane-coordinates-an-environment-it-does-not-carry-it.md)).
An episode is a session of turns, one per generation round, and routing it through the process
that decides admission would put a hop nowhere near the data on every one of them.

So when the environment sits on a network the cluster cannot reach, one of these has to be true:

| Approach | When it fits | What it costs |
| - | - | - |
| **Put the machine on the cluster's network** | The usual answer inside one organisation. A `node` Fleet already does it. | A network change, once. |
| **An overlay network** (Tailscale, Nebula, ZeroTier) | The environment is somewhere the cluster genuinely cannot route to, and you want it to look local. | An agent on the environment host and on the cluster. Data stays peer to peer — the coordinator never sees it. |
| **A reverse tunnel into the cluster** (frp, chisel, inlets) | One or two hosts, and you do not want a mesh. | A tunnel server deployed **in the cluster**. The environment dials out; the job reaches a cluster-local address. |

What none of them is: proxying through the console. If you find yourself wanting that, the answer
is usually the first row.

> [!NOTE]
>
> **Or remove the question.** An `openenv` (`deployment: container`) environment runs in a container beside the job, so
> there is no distance to cross and none of the three rows applies. Where the environment can be
> containerised, that is the route to reach for — and it is where the wider OpenEnv ecosystem is
> heading. It needs a kubernetes fleet today.

## The harness

The code that drives a model through an environment, supplied as a `capability: environment` plugin.

Needed for the **materialised** route, where the framework imports and runs it. Not needed for the
**served** turn-level route, where the trainer drives the episode itself.

```yaml plugin.yaml theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
schema: tuneplane/plugin/v1
name: calculator-harness
version: 1.0.0
capability: environment
entrypoint: harness:CalculatorResources
```

`harness.digest` in the manifest pins the harness package the environment was authored against.
Leave it empty when a team ships the harness alongside the environment; set it when the two are
versioned apart and a mismatched harness would score differently. It is verified at submission,
never at rollout.

## Push and reference

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp env push ./my-env --name calculator-tasks --version 1.0.0 --public
```

**--name** `\<owner\>/\<name\> or bare name` *required*

A bare name goes into your own namespace.

**--version** `string` *required*

Immutable once pushed.

**--public** `flag`

Only applies when the environment is first created.

The same parsing code runs in the CLI and in the control plane, from one module — so a manifest
that pushes is a manifest that runs. An unknown protocol or verifier kind is refused at push time,
not discovered at rollout, because an environment that validates and then cannot run has already
cost somebody a GPU allocation.

## Check it worked

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp env show alice/calculator-tasks@1.0.0
```

Prints the protocol, the verifier, the tools, and whether a sandbox is required. The console's
Environments page shows the same, plus which runs have referenced it.

## Next

[Verifiers](https://docs.tuneplane.ai/en/extend/verifiers.md) for the three ways completion is decided ·
[Writing rubrics](https://docs.tuneplane.ai/en/extend/rubrics.md) · [Using an environment](https://docs.tuneplane.ai/en/guides/agent-envs.md)

## Quality evidence

The user-owned runner `common/envkit/quality.py` in `tuneplane-examples` executes your callbacks,
then reports through `Reporter.environment_quality()`. It belongs in your project, not in the
control plane. There is no universal model/scorer implementation: connect the same verifier and
harness used by your environment, pin a checkpoint, and disable policy updates during measurement.

Create an adapter factory such as `my_quality:adapter` returning keyword arguments for `measure()`:

| Argument | Requirement |
| - | - |
| `environment` | Exact `<owner>/<name>@<version>` pinned by the job; the job submitter must own it |
| `checkpoint` | Immutable model revision or checkpoint reference |
| `runtime_digest`, `verifier_digest`, `harness_digest` | Actual SHA-256 revisions; a manifest-pinned harness digest must match |
| `predict(task, repetition)` | Frozen-policy generation; receives only `id` and `prompt`, never the reference or metadata |
| `verify(task, response)` | Original task plus prediction; returns `success`, `failure` or `unscored` |
| `fixtures` | Named callbacks returning `(actual, evidence)`; actual is `pass`, `fail` or `unknown` |
| `split`, `repetitions`, `settings` | Defaults are `train`, 4, and an empty object; record decoding settings and seeds explicitly |

Use `verifier_fixtures(verify, fixture_task, correct=..., incorrect=...)` to execute positive,
negative and verifier-execution fixtures against your real verifier. The negative fixture should
report `fail` when the known bad answer is rejected. For sandbox-required environments, add
`egress_allowed`, `egress_denied` and `reference_isolation` callbacks that actually probe your
runtime. Absence is never treated as success. Callbacks must set their own network/process timeouts.

A generation callback can return a string or `Prediction(text=..., tokens=...)`; missing token
usage stays unknown. Callback exceptions become `infra_error`, not model failures. The runner
records all planned task trials, saves a complete JSON report and a separate local fixture-evidence
file, then uploads metadata. Raw fixture evidence stays local; protect that file because it may
contain sensitive diagnostics. An upload failure leaves the report available locally and fails
explicitly. No model response or reference is included in the ingest report.

Inside the owner's job, with the environment version pinned:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
python -m common.envkit.quality \
  --adapter my_quality:adapter \
  --directory "$TUNEPLANE_JOB_ENVIRONMENT_DIR" \
  --output environment-quality.json \
  --upload
```

Use a job whose assigned environment split matches the measurement: a training operation for
training suitability, an evaluation operation for held-out measurement. Do not override
`TUNEPLANE_JOB_ENVIRONMENT_SPLIT`. Locally, omit `--upload` and point `--directory` at your original
authoring directory. This is a callable/CLI integration for an existing project job, not a new
one-click model execution service. GPU generation and sandbox enforcement remain the adapter's
runtime responsibilities.


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