# Packaging & Deploying

**Part 4 of 5** in the OpenEnv Getting Started Series

This guide walks you through creating a custom environment using the `OpenEnv` framework and the `openenv` CLI.

The CLI handles scaffolding, builds, validation, and deployment so you can stay focused on environment logic.

> [!NOTE]
> **New to OpenEnv?** If you're just getting started, we recommend completing the [Getting Started tutorials](../tutorials/index) first. They provide a conceptual introduction to OpenEnv and reinforcement learning fundamentals. This guide is for developers ready to build production-quality environments.

## Quick Reference Card

Already familiar with OpenEnv? Here's the 8-step process at a glance:

| Step | Command / Action | Description |
|------|------------------|-------------|
| 1 | `openenv init my_env` | Scaffold new environment |
| 2 | Edit `models.py` | Define Action & Observation dataclasses |
| 3 | Edit `server/my_environment.py` | Implement `reset()` and `step()` methods |
| 4 | Edit `client.py` | Implement `_step_payload()`, `_parse_result()`, `_parse_state()` |
| 5 | `openenv serve` | Start local dev server for testing |
| 6 | `openenv validate` | Validate environment structure |
| 7 | `openenv push` | Deploy to Hugging Face Hub |
| 8 | Share the URL! | Others use via `MyEnv.from_hub("you/my-env")` |

### CLI Quick Reference

| Command | Description |
|---------|-------------|
| `openenv init NAME` | Scaffold new environment |
| `openenv serve` | Start local dev server |
| `openenv build` | Build Docker image |
| `openenv validate --verbose` | Validate environment structure |
| `openenv push` | Deploy to Hugging Face Hub |
| `openenv push --repo-id NAME` | Deploy to specific repo |
| `openenv push --private` | Deploy as private environment |
| `openenv push --registry ghcr.io/ORG` | Push to GitHub Container Registry |

> [!TIP]
> For a hands-on tutorial that builds a complete environment step-by-step, see [Building Environments](../tutorials/index) in the Getting Started series.

---

## Overview

A typical workflow looks like:

1. Scaffold a new environment with `openenv init`.
2. Customize your models, environment logic, and FastAPI server.
3. Implement a typed `EnvClient` (WebSocket-based for persistent sessions).
4. Configure dependencies and the Dockerfile once.
5. Use the CLI (`openenv build`, `openenv validate`, `openenv push`) to package and share your work.

> [!NOTE]
>     These integrations are handled automatically by the `openenv` CLI when you run `openenv init`.

### Prerequisites

- Python 3.11+ and [`uv`](https://github.com/astral-sh/uv) for dependency locking
- Docker Desktop / Docker Engine
- The OpenEnv library installed: `pip install https://github.com/huggingface/OpenEnv.git`

## Step-by-Step Guide

Let's walk through the process of building a custom environment with OpenEnv.

### 1. Scaffold with `openenv init`

```bash
# Run from anywhere – defaults to current directory
openenv init my_env

# Optionally choose an output directory
openenv init my_env --output-dir /Users/you/envs
```

The command creates a fully-typed template with `openenv.yaml`, `pyproject.toml`, `uv.lock`, Docker assets, and stub implementations. If you're working inside this repo, move the generated folder under `envs/`.

Typical layout:

```
my_env/
├── __init__.py
├── README.md
├── client.py
├── models.py
├── openenv.yaml
├── pyproject.toml
├── uv.lock
└── server/
    ├── __init__.py
    ├── app.py
    ├── my_environment.py
    ├── requirements.txt
    └── Dockerfile
```

Python classes are generated for the action, observation, environment, and client. For example, you will find `MyEnvironment`, `MyAction`, `MyObservation`, and `MyEnv` (client) in the `my_env` directory based on the name you provided. The environment uses the core `State` class from `openenv.core.env_server.types`.

### 2. Define Models

Edit `models.py` to describe your action and observation using Pydantic:

```python
# models.py
from pydantic import Field
from openenv.core.env_server.types import Action, Observation

class MyAction(Action):
    """Your custom action."""
    command: str = Field(..., description="Command to execute")
    parameters: dict = Field(default_factory=dict, description="Command parameters")

class MyObservation(Observation):
    """Your custom observation."""
    result: str = Field(..., description="Result of the action")
    success: bool = Field(..., description="Whether the action succeeded")
```

### 3. Implement Environment Logic

Customize `server/my_environment.py` by extending `Environment`:

```python
# server/my_environment.py
from uuid import uuid4
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
from models import MyAction, MyObservation

class MyEnvironment(Environment):
    def __init__(self):
        self._state = State(episode_id=str(uuid4()), step_count=0)

    def reset(self) -> MyObservation:
        self._state = State(episode_id=str(uuid4()), step_count=0)
        return MyObservation(result="Ready", success=True, done=False, reward=0.0)

    def step(self, action: MyAction) -> MyObservation:
        # Implement your logic here
        self._state.step_count += 1
        result = self._execute_command(action.command)
        return MyObservation(result=result, success=True, done=False, reward=1.0)

    @property
    def state(self) -> State:
        return self._state
```

### 4. Create the FastAPI Server

`server/app.py` should expose the environment through `create_app`.

**Important:** You must pass a class or factory function (not an instance) to enable WebSocket-based concurrent sessions:

```python
# server/app.py
from openenv.core.env_server import create_app
from ..models import MyAction, MyObservation
from .my_environment import MyEnvironment

# Pass the class (factory) - each WebSocket session gets its own instance
app = create_app(MyEnvironment, MyAction, MyObservation, env_name="my_env")
```

For environments with constructor arguments, create a factory function:

```python
# server/app.py
import os
from openenv.core.env_server import create_app
from ..models import MyAction, MyObservation
from .my_environment import MyEnvironment

# Read config from environment variables
api_key = os.getenv("MY_API_KEY")
timeout = int(os.getenv("MY_TIMEOUT", "30"))

def create_my_environment():
    """Factory function that creates MyEnvironment with config."""
    return MyEnvironment(api_key=api_key, timeout=timeout)

# Pass the factory function
app = create_app(create_my_environment, MyAction, MyObservation, env_name="my_env")
```

### 5. Implement the Client

`client.py` extends `EnvClient` so users can interact with your server via WebSocket for persistent sessions:

```python
# client.py
from openenv.core.env_client import EnvClient
from openenv.core.client_types import StepResult
from .models import MyAction, MyObservation, MyState

class MyEnv(EnvClient[MyAction, MyObservation, MyState]):
    def _step_payload(self, action: MyAction) -> dict:
        return {"command": action.command, "parameters": action.parameters}

    def _parse_result(self, payload: dict) -> StepResult[MyObservation]:
        obs_data = payload.get("observation", {})
        obs = MyObservation(
            result=obs_data.get("result", ""),
            success=obs_data.get("success", False),
            done=payload.get("done", False),
            reward=payload.get("reward"),
        )
        return StepResult(
            observation=obs,
            reward=payload.get("reward"),
            done=payload.get("done", False),
        )

    def _parse_state(self, payload: dict) -> State:
        return State(
            episode_id=payload.get("episode_id"),
            step_count=payload.get("step_count", 0),
        )
```

The `EnvClient` maintains a persistent WebSocket connection to the server, enabling efficient multi-step interactions with lower latency compared to HTTP. Each client instance gets its own dedicated environment session on the server.

### 6. Configure Dependencies & Dockerfile

The CLI template ships with `pyproject.toml` and `server/Dockerfile`. You should manage your python dependencies with `uv` or `pip` in the `pyproject.toml` file. Other dependencies should be installed in the Dockerfile.

Keep building from the `openenv-base` image so shared tooling stays available:

Dockerfile

```dockerfile
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

# Multi-stage build using openenv-base
# This Dockerfile is flexible and works for both:
# - In-repo environments (with local src/core)
# - Standalone environments (with openenv from pip)
# The build script (openenv build) handles context detection and sets appropriate build args.

ARG BASE_IMAGE=openenv-base:latest
FROM ${BASE_IMAGE} AS builder

WORKDIR /app

# Build argument to control whether we're building standalone or in-repo
ARG BUILD_MODE=in-repo
ARG ENV_NAME=__ENV_NAME__

# Copy environment code (always at root of build context)
COPY . /app/env

# For in-repo builds, openenv is already in the pyproject.toml dependencies
# For standalone builds, openenv will be installed from pip via pyproject.toml
WORKDIR /app/env

# Install dependencies using uv sync
# If uv.lock exists, use it; otherwise resolve on the fly
RUN --mount=type=cache,target=/root/.cache/uv \
    if [ -f uv.lock ]; then \
        uv sync --frozen --no-install-project --no-editable; \
    else \
        uv sync --no-install-project --no-editable; \
    fi

RUN --mount=type=cache,target=/root/.cache/uv \
    if [ -f uv.lock ]; then \
        uv sync --frozen --no-editable; \
    else \
        uv sync --no-editable; \
    fi

# Final runtime stage
FROM ${BASE_IMAGE}

WORKDIR /app

# Copy the virtual environment from builder
COPY --from=builder /app/env/.venv /app/.venv

# Copy the environment code
COPY --from=builder /app/env /app/env

# Set PATH to use the virtual environment
ENV PATH="/app/.venv/bin:$PATH"

# Set PYTHONPATH so imports work correctly
ENV PYTHONPATH="/app/env:$PYTHONPATH"

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Run the FastAPI server
# The module path is constructed to work with the /app/env structure
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]

```

If you introduced extra dependencies in the Dockerfile, you should install them in the Dockerfile before removing temp files.

### 7. Build & Validate with the CLI

From the environment directory:

```bash
cd envs/my_env
openenv build          # Builds Docker image (auto-detects context)
openenv validate --verbose
```

`openenv build` understands both standalone environments and in-repo ones. Useful flags:

- `--tag/-t`: override the default `openenv-<env_name>` tag
- `--build-arg KEY=VALUE`: pass multiple Docker build arguments
- `--dockerfile` / `--context`: custom locations when experimenting
- `--no-cache`: force fresh dependency installs

`openenv validate` checks for required files, ensures the Dockerfile/server entrypoints function, and lists supported deployment modes. The command exits non-zero if issues are found so you can wire it into CI.

### 8. Push & Share with `openenv push`

Once validation passes, the CLI can deploy directly to Hugging Face Spaces or any registry:

```bash
# Push to HF Spaces (auto enables web UI and prompts for login if needed)
openenv push

# Push to a specific repo or namespace
openenv push --repo-id my-org/my-env

# Push to Docker/ghcr (interface disabled by default)
openenv push --registry ghcr.io/my-org --tag my-env:latest

# Customize image base or visibility
openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest --private

# Configure Space variables and secrets at push time
openenv push -e OPENSPIEL_GAME=tic_tac_toe --secret OPENAI_API_KEY=sk-...
```

Key options:

- `--directory`: path to the environment (defaults to `cwd`)
- `--repo-id`: explicit Hugging Face space name
- `--registry`: push to Docker Hub, GHCR, etc.
- `--interface/--no-interface`: toggle the optional web UI
- `--base-image`: override the Dockerfile `FROM`
- `--private`: mark the space as private
- `--env-var/-e KEY=VALUE`: set a public Space variable (repeatable); overrides matching keys from `variables:` in `openenv.yaml`
- `--secret KEY=VALUE`: set a private Space secret (repeatable); value is never logged

The command validates your `openenv.yaml`, injects Hugging Face frontmatter when needed, and uploads the prepared bundle.
Space variables and secrets are only applied on direct Hugging Face Space pushes;
they are not available for `--registry`, and they cannot be staged through
`--create-pr`.

#### Declaring public variables in `openenv.yaml`

For defaults that belong with the environment (game name, benchmark, max
steps…), add a `variables:` block to `openenv.yaml`; `openenv push` will apply
them to the Space automatically:

```yaml
variables:
  OPENSPIEL_GAME: catch
```

CLI `-e` overrides matching keys. Put secrets (API keys, tokens) **only** on
the CLI via `--secret KEY=VALUE` — never in the yaml.

### 9. Automate Builds (optional)

To trigger Docker builds on every push to `main`, add your environment to the matrix in `.github/workflows/docker-build.yml`:

```yaml
strategy:
  matrix:
    image:
      - name: echo-env
        dockerfile: envs/echo_env/server/Dockerfile
      - name: chat-env
        dockerfile: envs/chat_env/server/Dockerfile
      - name: coding-env
        dockerfile: envs/coding_env/server/Dockerfile
      - name: my-env  # Add your environment here
        dockerfile: envs/my_env/server/Dockerfile
```

### Use Your Environment

Here is a simple example of using your environment:

```python
from envs.my_env import MyAction, MyEnv

# Create environment from Docker image
client = MyEnv.from_docker_image("my-env:latest")
# Or, connect to the remote space on Hugging Face
client = MyEnv.from_hub("my-org/my-env")
# Or, connect to the local server
client = MyEnv(base_url="http://localhost:8000")

# Use context manager for automatic cleanup (recommended)
with client:
    # Reset
    result = client.reset()
    print(result.observation.result)  # "Ready"

    # Execute actions
    result = client.step(MyAction(command="test", parameters={}))
    print(result.observation.result)
    print(result.observation.success)

    # Get state
    state = client.state()
    print(state.episode_id)
    print(state.step_count)

# Or manually manage the connection
try:
    client = MyEnv(base_url="http://localhost:8000")
    result = client.reset()
    result = client.step(MyAction(command="test", parameters={}))
finally:
    client.close()
```

## Nice work! You've now built and used your own OpenEnv environment.

Your next steps are to:

- [Try out the end-to-end tutorial](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb)

