Environments
An Environment is the world an Agent works in. It is a set of Apps, the systems the Agent can use, plus the Runtime they run in and the limits of one attempt. You build it once and reuse it for many assignments, so every Agent is tested on the same terms.
Think of a game of Wordle. The Environment has one App, the game: the Agent can submit a guess, it sees green, yellow, and gray tiles after each one, and the secret word stays hidden. Each secret word is a separate Task, one assignment in that world.
A support desk works the same way with more Apps. A CRM, reached through its MCP server, holds the tickets. A table of refund policies sits beside it. Each Task picks one ticket, and a Verifier, the grader, checks whether the work was done correctly.
How an Environment works
Every Environment is built from a few parts. Here they are for both examples:
| Part | What it means | Wordle | Support desk |
|---|---|---|---|
| Apps | The systems the Agent works with | The game | The CRM and the refund policies |
| Actions | The moves the Apps offer | guess | The CRM's tools, policies__list_refunds |
| State | Each App's private truth | The secret word, the guesses so far | The tickets and their status |
| Observation | What an action returns, the only thing the Agent sees | The colored tiles | A ticket, a policy row |
| Resources | Files already in the world | A word list | A policy document |
| Runtime | Where the Apps and the Agent run | Your own machine in the tutorial; a sealed Docker container for code you don't trust | The same |
The Agent works in a loop. It picks an action, the App that owns it runs it and may change its State, and what the action returns is the Agent's next observation. That repeats until the work is done or a budget runs out.
Loading diagram…
Diagram text
- Agent → Action (guess, get ticket)
- Action (guess, get ticket) → App (keeps private State)
- App (keeps private State) → Observation (what the action returned)
- Observation (what the action returned) → Agent
flowchart LR
agent[Agent]
act["Action<br/>(guess, get_ticket)"]
app["App<br/>(keeps private State)"]
obs["Observation<br/>(what the action returned)"]
agent --> act
act --> app
app --> obs
obs --> agentGood to know: The Agent only ever sees the Task's instructions and what its actions return. It never sees an App's State, a score, or a reward.
Two things sit just outside this loop. Rewards, small bits of credit for individual moves when you train a model, come from the Task's Verifiers, never from the Environment. Credentials, such as the CRM token the Agent acts with, are declared by name on the App that needs them and supplied by the Agent that runs.
Start with the work you want to measure
Before writing code, answer five questions:
- What should the Agent accomplish? Choose one workflow, such as resolving a support ticket.
- Which systems does it use? Each one is an App. Connect the ones that already exist, such as an MCP server, and write the rest in Python.
- What can it do? Offer a few focused actions that match the real workflow.
- How will you know it succeeded? Keep the expected outcome in State, where a Verifier can read it.
- When should it stop? Decide what counts as finished, and set a turn and time budget. See How an episode ends.
Start small. Add cases and complexity once you can explain why a known good run passes and a known bad run fails.
Actions, State, and what the Agent sees
These three parts describe the interaction:
- Actions are the operations the Agent can call, such as
categorizeorresolve. - State holds an App's private data: the expected answer and every change made during the run.
- The observation is whatever an action returns. It is the only thing the Agent sees after acting.
So return what the Agent should see, and never copy State into it. A Verifier reads the final State to check the result.
This split controls what Plural shows the model. It is not a security boundary; when you run code you don't trust, use Runtime isolation as well. See Runtime.
Define the Environment
This small example lets an Agent sort one support request into the right team. It is one Python App in one Environment:
from plural import App, Environment, Runtime, State, action, initial class Ticket(State): issue: str = initial("I was charged twice.", description="The customer's message.") expected: str = initial("billing", description="The category this ticket should receive.") category: str = "" class TicketTriage(App[Ticket]): """Sort a support request into the right team.""" def reset(self, initial_state): super().reset(initial_state) return f"{self.state.issue}\nChoose billing, technical, or account." @action def categorize(self, category: str) -> str: """Assign the ticket to billing, technical, or account.""" if category not in {"billing", "technical", "account"}: raise ValueError("Choose billing, technical, or account") self.state.category = category return f"Category: {category}" def terminated(self) -> bool: return bool(self.state.category) environment = Environment(name="ticket-triage", apps=[TicketTriage()], runtime=Runtime.local())Reading it from the top:
Ticketis the App's private State. Fields marked withinitial()are the ones a Task can set, so each Task can bring its own ticket and expected category.resetstarts a fresh attempt. The default loads the Task's starting values intoself.state; whatresetreturns is the first thing the Agent sees.categorizeis an action, marked with@action. Its docstring tells the model what it does, and what it returns is what the Agent sees next.terminatedtells Plural the ticket is done.Environmentputs the App in a Runtime. The App's name defaults to its class name in snake case,ticket_triage. App names use lowercase letters, digits, and underscores, because they prefix action names when there are several Apps.
runtime is a required Environment parameter. It selects where the Apps and the Agent run. This example uses your own machine, which is fine for trusted development.
Typed action parameters tell the model which inputs each action accepts, and clear docstrings help it use them well. When an action raises ValueError, the Agent sees the message and the episode continues, so it can fix its input and try again. See Actions for everything an action can return.
To use several Apps, list them all. To add an effect that spans them, such as closing a CRM ticket when a refund goes through, subclass Environment and override on_step. See Compose Apps.
The directory that contains the code is its source package. Keep the code and data it needs there, and keep credentials, caches, and generated results elsewhere. Plural records the source hash and copies the package into the Runtime.
Save the Environment in a project
In a project, an Environment is a folder under environments/, named after the Environment. Create one from the standard template:
plural env init ticket-triageenvironments/ticket-triage/├── environment.yaml├── server.py├── README.md└── resources/policy.mdThe App is the FastMCP server in server.py; see Build an MCP server. With --python, the template is a Python App class in environment.py instead. Either way the folder is the Environment's source package, and resources/ holds its data files. The template also writes a starter Task, ticket-triage-first, and its Verifier, so plural run --task ticket-triage-first --model MODEL works at once; --bare leaves them out. Create an Environment walks through replacing the example, and every other way to start. environment.yaml holds everything else: its name, Apps, resources, Runtime, and settings. This is the manifest from the first project, which uses a Python App:
name: support-queueversion: 1.0.0description: A customer support queue with one open ticket per Task.overview: Inspect a customer ticket, categorize it, draft a reply, and resolve it.python: environment.py:SupportQueuereadme: README.mdresources: - resources/policy.mdruntime: provider: localharness_policy: mode: allow_alllimits: max_turns: 6 max_seconds: 120To connect an existing system instead of writing one, add it: plural app add NAME --mcp URL_OR_COMMAND, --openapi SPEC, --traces FILE, or --files-server creates the folder, or adds an App to an Environment you already have. Run it once per system. plural app list NAME lists an Environment's Apps, and plural app remove NAME APP takes one App's entry out of environment.yaml, leaving its files in place. See Start from a system.
What each field means:
namemust match the folder name. The name and overview come from the manifest.pythonnames either one App class, asfile.py:Class, for an Environment of one Python App, or anEnvironmentsubclass whoseon_stepspans the Apps listed inapps.appslists the Apps. Each entry is a Python App (python: apps/crm.py:CRM, with optionalconfig), or a namedmcp,openapi, orreplayApp with that kind's settings. See Apps.readmepoints toREADME.md, which explains the Apps, their actions and State, and settings to the people who use the Environment.resourceslists files to stage, relative to the folder. A path is the short form; a mapping takes the same fields asResource.runtimepicks a provider withprovider, such asdocker,local, ordaytona. The other keys are that provider's settings, such asimageorcpus;plural runtime provider <provider>lists them. The template useslocal, which runs on your machine and is not a sandbox. To reuse a project Runtime, runplural runtime use <runtime> <environment>, which fills this block in for you.harness_policy,limits,guardrails, andmetadatatake the same values as the Environment arguments of those names.partymakes it an Environment several Agents share, such as{min: 2, max: 8}for poker. See Several Agents in one Task.
Every path must stay inside the Environment folder. Check the Environment, then save it to your hosted project:
plural env validate ticket-triageplural env push ticket-triagevalidate loads every App and prints its kind, control level, actions, and secrets, then the tools exactly as the model will see them. A push saves an unchangeable revision that stays private to the project. It refuses files that look like credentials and paths that escape the folder. To leave out other files, list them in a .pluralignore file in the Environment folder. Credentials are never stored in the project; you supply them when the Job runs, as described in Secrets.
How an episode ends
An episode is one play-through, from reset to the end. Every Agent action is one step, which returns the same five values Gymnasium uses. You can drive an Environment yourself the same way, which is handy in tests:
observation, info = environment.reset(initial_state={"expected": "billing"})observation, reward, terminated, truncated, info = environment.step("categorize", category="billing")The Agent only ever sees the observation. The other values describe what happened, and Plural records them on the episode instead of showing them to the model, so a reward or an end-of-game flag never leaks into the model's context.
An episode can stop for three reasons. Each records a different stop_reason on the Trial, the record of one attempt. The full set of values is plural.STOP_REASONS.
- An App ends it. Override
terminated()on an App when its system reaches a success or failure state for the Task, as the example above does once the ticket is categorized. Overridetruncated()when the episode can't usefully continue but reached neither outcome. Both default toFalse. The episode ends when any App says so; override them on anEnvironmentsubclass to decide differently. - The Agent ends it. Plural adds a
finishtool alongside the Apps' actions. The Agent calls it when the work is done or when it can't make progress, and its optionalsummarybecomes the Trial's response. - A budget cuts it short.
max_turns,max_seconds, andmax_cost_usdtruncate the episode instead of failing the Trial, so you still get a trajectory and Verifier results.
terminated and truncated are recorded separately from stop_reason, because neither is set when the Agent chose to stop. Read stop_reason to tell an Agent that finished early from a world that declared the Task over.
Rewards
A reward is a small bit of credit for one move. It answers "which action deserved the credit?", which is what a training system needs. A score answers a different question: did the attempt succeed? Rewards come from the Task's Verifiers and are recorded on the episode in every mode.
An Environment gives no rewards itself. What deserves credit depends on the Task, so rewards come from the Task's Verifiers: a Verifier's reward function runs right after each action, beside the Apps, and can read every App. See Reward each step.
The precise rules
- The
rewardreturned bystepis the total of everyrewardfunction that gave one.info["rewards"]breaks that total down by Verifier, with its version and the App that acted. previous_stateandcurrent_stateare detached JSON snapshots of the acting App, so read them like dictionaries rather than as yourStateclass. An MCP App provides them only withsnapshot_on_step: true.- A
rewardfunction that raises is recorded as0.0, with its error in that breakdown, because a bug in a reward must not fail an action that already changed the world. on_stepon anEnvironmentsubclass changes the world after an action. It can't return a reward.
Rewards can also be assigned after the fact, from a whole trajectory, and every reward ends up in the Trial's reward ledger. A trainer reads them from there; see Training and RL.
Connect Tasks and scoring
The example above always uses the same ticket. For a useful benchmark, reuse the Environment with different case data. Each Task supplies instructions, picks its case, and attaches one or more Verifiers.
A few rules decide what a Task can pass in:
initial_statesets each App's starting State, keyed by App name, such as{"crm": {...}, "policies": {...}}. With one App, give its State directly.- Only State fields marked with
initial()can be set. Pass constraints such asmin_length=5orpattern=r"^[a-z]{5}$"when a Task's value must match a rule. - Unmarked fields, such as a running list of guesses, belong to the episode, and a Task cannot set them.
- Task
infois public, so it can hold a ticket ID but should not hold the answer key. An App reads it inresetasself.task_info.
The support queue tutorial shows how an App loads the Task's ticket and how Verifiers score the finished workflow.
Display views
A view is what people see when they watch a run in the run viewer. It is separate from the observation, which is what the model sees. Override view() on an App to control it. Plural records the view after the reset and after every step, for display and replay only. It never reaches the Agent, and it is not scored. With several Apps, the viewer shows the view of the App that just acted.
A view is plain data, not code. It declares a schema and a kind that the viewer knows how to draw, so a run can never ship its own rendering script:
def view(self): return { "schema": "plural.view/v1", "kind": "marks-grid", "title": "Wordle", "columns": 5, "max_rows": 6, "rows": [{"letters": "crane", "marks": ["absent", "present", "correct", "absent", "absent"]}], "status": "5 guesses left", }Version 1 defines these kinds:
marks-griddrawsrowsofletters, each letter markedcorrect,present, orabsent, with an optionaltitle,columns,max_rows, andstatus.markdownshowsmarkdownas plain text.key-valuesshowsitems, a list of{"label": ..., "value": ...}pairs.
The viewer shows any other view, or one it can't read, as formatted JSON beside the observation.
Good to know: Everyone who can read the run can see its view, the same as the observation. Leave out anything that people with read access should not see. The Wordle example shows guesses and marks, never the secret word.
Runtime
The Runtime is where the world runs: a sealed container, your own machine, or a remote machine. Choose one based on where the code should run and which controls you need. Plural checks support before running and fails if the chosen provider can't enforce a control you asked for.
In short:
- Local runs on your computer. Use it only for code you trust.
- Docker runs in a sealed container on a machine with Docker installed.
- Remote runs on a machine from a remote provider. The bundled provider is Daytona.
Local
Runtime.local()Use this for trusted development. Plural runs a subprocess in a temporary workspace on your machine. Local execution does not provide a sandbox, network isolation, or compute limits. Runtime.local() explicitly enables local execution through allow_unsafe_local.
Docker
Runtime.docker()Runtime.docker(image="my-org/eval:1")Runtime.docker(cpus=2, memory_mb=2048, read_only_root=True)Use Docker for container isolation on a machine with Docker installed. The default image is python:3.12-slim. The workspace stays writable when the root filesystem is read-only. Pin an image digest when you need reproducible system dependencies.
To install more dependencies, put a Dockerfile beside the Environment:
FROM python:3.12-slimWORKDIR /workspaceCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtRuntime.docker(dockerfile="Dockerfile", build_context=".")build_context is the folder Docker uses for the build. Builds time out after 600 seconds by default. Choose one image source: a registry image, a snapshot, a declarative image, or a Dockerfile.
Remote
Use a remote Runtime to run away from your machine. The bundled provider is Daytona. Install the remote integration and set DAYTONA_API_KEY:
python -m pip install "plural[daytona]"Runtime.daytona(image="python:3.12-slim")Remote execution needs an image or snapshot the provider can reach. It can't build from a Dockerfile on your laptop. See Providers and integrations for setup and extensions, and Runtimes to set up a Daytona sandbox once and reuse it across Environments.
Network
Model calls run inside the Runtime, so it needs to reach the model endpoint. Choose the narrowest network access that still supports your workflow:
publicallows outbound connections, and is the default.no-networkblocks outbound connections.allowlistpermits only the hosts listed inallowed_hosts, on a provider that supports it.
Local execution can't isolate networking. Docker can block outbound traffic but does not enforce a host allowlist. The remote provider supports allowlists:
Runtime.daytona( image="python:3.12-slim", network="allowlist", allowed_hosts=("api.openai.com",),)Use your actual model endpoint hostname. Verifiers have their own VerifierRuntime; model judges also need to reach their endpoint.
Compute, timeouts, and persistence
cpus and memory_mb request compute limits on providers that support them. The Runtime's timeout_seconds defaults to 300 seconds for execution and upload operations. Environment limits separately bound the episode.
The two meet at the end of an episode. Plural's built-in Harness ends the episode itself at max_seconds, with the stop reason max_seconds, and its Trial is verified like any other; the Runtime allows it 30 seconds past that to finish. When timeout_seconds is the shorter of the two, it wins: the Harness stops 30 seconds before it. So raise timeout_seconds along with a max_seconds above 270. A Harness of your own is killed at max_seconds (or timeout_seconds, if shorter), and is told that deadline, in seconds, in PLURAL_STOP_SECONDS. A Trial killed that way fails with the stop reason harness_timeout; a Benchmark's harness_timeout rule decides whether it counts against the Agent.
Set the episode limits on the Environment:
from plural import Environment, ExecutionLimits, Runtime environment = Environment( apps=[TicketTriage()], runtime=Runtime.local(), limits=ExecutionLimits(max_turns=4, max_seconds=60),)The default episode limits are eight turns and 120 seconds. An optional max_cost_usd sets a cost budget, based on the usage the runner can measure.
Local, Docker, and the bundled remote provider do not keep workspaces between Trials. Recreate the data each Trial needs in reset.
Resources
Resources are the files and data the world starts with, like the Wordle word list or the support policy. Think of them as volumes. The Environment has one, shared by every Task. A Task may have its own, for one case, such as a single customer's invoice. A Trial references both, read-only; it doesn't own them, and nothing done in a Trial changes them. The two sit side by side and never share a path:
/workspace/resources/ environment-resources/ # The Environment's files, for every Task policies/refunds.md task-resources/ # This Task's files, when it has any invoice.csv manifest.json # Every file's location and SHA-256 hashUse relative file paths. PLURAL_RESOURCES_DIR holds the root folder. A FastMCP server reads the two folders with plural.mcp.environment_resources() and plural.mcp.task_resources(); see Build an MCP server. For files the Agent should browse and query directly, add Plural's files server as an App: server: plural.mcp.files:server.
The Agent reaches the files through the Apps' tools. A Harness that can read files, such as a coding agent, can also read both folders directly.
Good to know: Files are not added to the model's prompt automatically. The Agent only learns what a tool returns.
There are four ways to supply a resource. The short form covers most cases; the rest are for power users.
The short form
A file that already lives next to your Environment code needs one line. Plural stages it from the source package automatically:
from plural import Resource policy = Resource("policies/refunds.md")That is kind="file" and delivery="source", with the name defaulting to the path. Pass resources=[policy] when you create the Environment. In environment.yaml, list the same path under resources.
Saved text and configuration
To keep a small text file with the Environment itself, pass its content:
from plural import Resource policy = Resource( "policies/refunds.md", content="Refund duplicate charges after confirming the invoice.", content_type="text/markdown",)Pass resources=[policy] when you create the Environment. Plural calculates the content hash and writes the file before reset. Inline resources support text, CSV, JSON, and other UTF-8 configuration, up to 1 MiB of characters and the 16 MiB staged-file limit. They are stored with the Environment, so use runtime secrets for credentials.
Packaged files
The short form already stages a file from the Environment's source package into the resource folder. The explicit mapping form says the same thing:
policy = Resource( kind="file", name="policies/refunds.md", path="policies/refunds.md", delivery="source",)The path must point to a file inside the package. Source files may be binary. Each staged file is limited to 16 MiB. The manifest records the actual hash; an optional digest requires an exact match.
Data resolved at launch
For data that lives elsewhere, such as in your ticketing system, store its location, a resolver name, configuration, and the expected hash. Then give the Job the resolver that fetches it:
import hashlibfrom plural import Job, Resource expected_bytes = b'{"ticket_id": "ticket-123", "category": "billing"}'ticket = Resource( kind="data", name="ticket", path="ticket.json", delivery="resolver", uri="support://tickets/ticket-123", resolver="support-ticket", config={"ticket_id": "ticket-123"}, digest="sha256:" + hashlib.sha256(expected_bytes).hexdigest(),) def load_ticket(resource: Resource) -> bytes: # Replace this fixture with a read from your data service. # Set a timeout on external calls and return a stable encoding. assert resource.config["ticket_id"] == "ticket-123" return expected_bytes # Add ticket to the Task's resources before creating this Job.job = Job(task, agents=[agent], resource_resolvers={"support-ticket": load_ticket})The rules:
- Resolvers run in the Job process before the Agent starts. They receive the resource declaration and return bytes.
- Plural rejects missing resolvers and mismatched hashes.
- Register resolvers in the process that runs the Job. Naming a resolver in a manifest or in the web app does not install it for hosted runs.
- A URI alone is never fetched automatically.
A Resource declared with explicit fields and no delivery is a descriptor: Plural records the reference but stages no file, and your Environment code must load the data itself.
Secrets
An App never holds a credential's value; it lists the names it needs, and they are supplied when a run starts, so nothing secret is saved with the Environment. A credential is supplied by the Agent that runs, so each Agent acts with its own token and scopes. A secret is supplied by the project, for setup only, such as a reset hook's admin token:
from plural import App crm = App.mcp( "crm", url="https://mcp.example.com/v1", headers={"Authorization": "Bearer ${CRM_TOKEN}"}, credentials=["CRM_TOKEN"],)A Job checks before it starts anything that the Agent declares every credential and has a value for each, and that every secret has a value, and fails at once, naming the App and the name. The Agent lists its credentials, with their scopes, in its agent.yaml, and each person who runs it saves their own value with plural credential set NAME. A secret comes from your shell when you exported it, and otherwise from the hosted project, where plural secret set NAME stores it encrypted. Values go only to the process that runs the Apps and to Verifier queries, never to the model or its Harness. See Credentials and secrets.
Runtime variables
Settings that belong to the Runtime rather than one App, such as the address of a service the Environment's code reads, are declared as variables on the Runtime and supplied when the Job starts:
import osfrom plural import Job, Runtime, RuntimeVariable runtime = Runtime.docker(variables=[ RuntimeVariable( name="SUPPORT_API_URL", format="url", description="Base URL of the support API.", ),]) # Use this runtime when constructing your Environment and Task.job = Job(task, agents=[agent], environ={"SUPPORT_API_URL": os.environ["SUPPORT_API_URL"]})The precise rules:
- Required declarations must have a nonempty value. Set
required=Falsefor optional inputs. - Supported formats are
text,url,integer, andjson. - Values are injected into the Apps' process. Declarations never contain values.
- The Harness, the loop that drives the Agent, shares the Runtime and can read Runtime variables, so put credentials on Apps instead.
Docker and Daytona connection credentials configure the machine that runs the Job, separately from these variables. Plural checks that the provider is available and supports the requested controls when a run starts.
Model calls are authenticated through the Job's client or api_key parameter, and go through the Plural gateway, so plural run needs a Plural API key: one stored with plural auth login, or an exported PLURAL_API_KEY. An Agent that calls its own OpenAI-compatible endpoint needs none; see Agents.
Optional components
- Metadata: use
name,version, andoverviewto identify the Environment; usemetadatafor labels and ownership. - Display: return an
Observationwith arender()method from an action to control the text the model reads exactly, and overrideApp.view()to control what people see in the run viewer. See Display views. - Harness policy: limit which custom Harnesses may run here and what they may do. See Harnesses.
- Rewards: give per-step credit from a Task's Verifiers. See Rewards.
Build a complete example
Continue with Tasks to add cases to your Environment, or follow an end-to-end tutorial:
- Support queue: inspect, categorize, answer, and resolve three tickets.
- Wordle: guess a hidden word using feedback from one action.