OpenEnv documentation
Your First Environment
Your First Environment
This walkthrough builds an environment from scratch: scaffold it with the openenv CLI, write the models, the environment logic, the server and the client, run it locally, then deploy it to a Hugging Face Space. For what each piece is, see Core Concepts.
You need Python 3.10+, uv and the OpenEnv library (pip install openenv). Docker is only needed to build the image or run it locally (openenv build, from_env and from_docker_image).
1. Scaffold with openenv init
openenv init my_env
cd my_envUse --output-dir to create it somewhere else. The command generates a working environment that echoes back messages, plus a uv.lock:
my_env/
├── __init__.py # Exports the client and models
├── README.md # Documentation, also used as the Space card
├── client.py # MyEnv: the client
├── models.py # MyAction, MyObservation
├── openenv.yaml # Manifest
├── pyproject.toml # Package metadata and dependencies
├── uv.lock
└── server/
├── __init__.py
├── app.py # FastAPI app built with create_app
├── my_env_environment.py # MyEnvironment: reset(), step(), state
├── requirements.txt
└── DockerfileClass names come from the environment name, with a trailing _env dropped: my_env gives MyAction, MyObservation, MyEnvironment and MyEnv. The steps below go through each file. Edit them to replace the echo logic with yours. If you’re working inside the OpenEnv repo, move the folder under envs/.
If you already have an ORS/OpenReward or Prime Intellect Verifiers environment, run
openenv import SOURCE --name my_env --output-dir DIRinstead. It detects the source type, vendors the source under the generated package and emits an OpenEnv wrapper with task/split and MCP-style tool actions. Non-secret data files and portable dependencies from the source tree are carried over.
2. Define the models
models.py declares what the agent sends (the action) and what it gets back (the observation). Subclass the base classes from openenv.core.env_server.types, not pydantic.BaseModel directly:
from openenv.core.env_server.types import Action, Observation
from pydantic import Field
class MyAction(Action):
message: str = Field(..., description="Message to echo back")
class MyObservation(Observation):
echoed_message: str = Field(default="", description="The echoed message")
message_length: int = Field(default=0, description="Length of the echoed message")The base Observation already has done, reward and metadata fields. The template uses the core State class (episode_id, step_count). Subclass State if you need to track more.
3. Implement the environment
server/my_env_environment.py holds the logic. Subclass Environment and implement reset(), step() and the state property. Reward and termination go on the returned observation, step() does not return a tuple:
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):
SUPPORTS_CONCURRENT_SESSIONS: bool = True
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(echoed_message="My Env environment ready!", done=False, reward=0.0)
def step(self, action: MyAction) -> MyObservation:
self._state.step_count += 1
length = len(action.message)
return MyObservation(
echoed_message=action.message,
message_length=length,
done=False,
reward=min(length * 0.1, 1.0),
)
@property
def state(self) -> State:
return self._stateThe full method signatures are reset(self, seed=None, episode_id=None, **kwargs) and step(self, action, timeout_s=None, **kwargs). Accept those arguments when your environment uses them. Set SUPPORTS_CONCURRENT_SESSIONS = True only if instances share no mutable state, so several clients can each get their own instance.
For anything beyond a one-line reward, compute it with a rubric: pass rubric=... to super().__init__(), call self._reset_rubric() in reset() and self._apply_rubric(action, observation) in step(). See Rewards and the Rubrics tutorial.
4. Create the server
server/app.py wraps the environment as a FastAPI app with create_app. Pass the environment class, not an instance. The server calls it to create one instance per WebSocket session:
from openenv.core.env_server.http_server import create_app
from ..models import MyAction, MyObservation
from .my_env_environment import MyEnvironment
app = create_app(
MyEnvironment,
MyAction,
MyObservation,
env_name="my_env",
max_concurrent_envs=1, # raise it to allow more concurrent sessions
)If the environment takes constructor arguments, pass a factory function instead of the class:
import os
def create_my_environment():
return MyEnvironment(api_key=os.getenv("MY_API_KEY"))
app = create_app(create_my_environment, MyAction, MyObservation, env_name="my_env")The generated files also wrap these imports in try/except ImportError so the server runs both as a package and from inside the folder (as in the Docker image). Keep that pattern when you edit them.
5. Implement the client
client.py subclasses EnvClient and converts between your models and the JSON sent over the WebSocket. Update these three methods when you change the models:
from openenv.core import EnvClient
from openenv.core.client_types import StepResult
from openenv.core.env_server.types import State
from .models import MyAction, MyObservation
class MyEnv(EnvClient[MyAction, MyObservation, State]):
def _step_payload(self, action: MyAction) -> dict:
return {"message": action.message}
def _parse_result(self, payload: dict) -> StepResult[MyObservation]:
obs_data = payload.get("observation", {})
observation = MyObservation(
echoed_message=obs_data.get("echoed_message", ""),
message_length=obs_data.get("message_length", 0),
done=payload.get("done", False),
reward=payload.get("reward"),
)
return StepResult(
observation=observation,
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),
)6. Run it locally
From the environment folder, start the server on port 8000:
uv run --project . server
To use another port, run uvicorn directly: uv run --project . uvicorn server.app:app --port 8001.
In another terminal, connect with the client. The client is async by default. .sync() gives a synchronous wrapper:
from my_env import MyAction, MyEnv
with MyEnv(base_url="http://localhost:8000").sync() as client:
result = client.reset()
print(result.observation.echoed_message) # "My Env environment ready!"
result = client.step(MyAction(message="Hello!"))
print(result.observation.echoed_message, result.reward) # "Hello!" 0.6
print(client.state()) # episode_id=... step_count=1Save it as try_it.py and run uv run --project . python try_it.py so my_env is importable. See Async vs Sync for the async version.
Web UI
Start the server with ENABLE_WEB_INTERFACE=true and open http://localhost:8000/web:
ENABLE_WEB_INTERFACE=true uv run --project . serverYou get a playground to reset the environment, fill an action form, step and read the observations, with no extra code. openenv push turns it on for Spaces. Optionally, override render_web() to draw the observation and web_actions() to offer one-click actions. See Customizing the Web UI.
Validate
openenv.yaml is the environment’s manifest. It names the app and port the server runs, and declares the contract your environment promises (reward range, resources, capabilities). The template generates:
spec_version: 1
name: my_env
version: 0.1.0
type: space
runtime: fastapi
app: server.app:app
port: 8000
validation:
reward:
range: [0.0, 1.0]
oracle_tolerance: 0.0
floor_margin: 0.1
resources:
cpu: 1.0
memory_mb: 1024
disk_mb: 512
episode_timeout_s: 60.0
capabilities:
verifier:
kind: reward_channel
types:
tags: [demo]Check it before deploying:
openenv validate --level static --skip-build
7. Deploy
Push the environment to a Hugging Face Space:
openenv push
openenv push logs you in if needed, enables the web UI and uploads the folder to the Space <your-username>/my_env, which builds the Docker image. To check that the image builds before pushing, run openenv build (needs Docker).
Others can then run your environment. from_env pulls the Space’s image and starts it locally with Docker:
from my_env import MyEnv
with MyEnv.from_env("your-username/my_env").sync() as client:
result = client.reset()Deploying an Environment covers the build and push options, Space variables and secrets, other registries and how to connect to a deployed environment.
Next steps
- Rewards and Rubrics to design the reward
- MCP Environments to expose tools instead of a single action type
- Training to train a model on your environment