Plural
Docs

Apps

An App is one system an Agent works with. Wordle is an App with one action, guess. A CRM reached through its MCP server is an App whose tools are its actions. An Environment is a set of Apps plus the Runtime they run in, so a support desk might combine a CRM, an orders API, and the support policy.

Every App does the same three things, whatever kind it is:

  • Offers actions. Each action has a name, a description the Agent reads, and typed parameters. What an action returns is what the Agent sees next.
  • Keeps state the Agent never sees. A Verifier reads it at the end to check the work. The Agent only sees what actions return.
  • Declares the credentials it needs, such as CRM_TOKEN. The Agent that runs supplies the values, so it acts as its own user with its own scopes. They reach only the App, never the model or its Harness.

Kinds of App

Most Apps are MCP servers. Pick the row that matches what you have:

You haveKindStart with
Nothing yet; you want to write the systemmcp, a server you write with FastMCPplural env init
A hosted MCP server, such as your CRM'smcp, reached by URLplural app add ENV --mcp URL, or Add system on the Environment's Apps tab
Files: documents, CSV, JSON, or SQLitemcp, Plural's files serverplural app add ENV --files-server
An HTTP API with an OpenAPI documentopenapiplural app add ENV --openapi SPEC
Recorded traces of an agent at workreplayplural app add ENV --traces FILE
A simulation you'd rather write as a class, such as a gamePython, a subclass of Appplural env init --python

A server you write is an ordinary FastMCP server. Every tool is an action; its docstring is what the Agent reads, and what it returns is what the Agent sees next:

python
from fastmcp import FastMCP import plural.mcp mcp = FastMCP("desk")TICKETS: list[dict] = []  @mcp.tool(annotations={"readOnlyHint": True})def list_tickets() -> list[dict]:    """List open tickets."""    return [ticket for ticket in TICKETS if ticket["status"] == "open"]  @mcp.tooldef close_ticket(id: str) -> str:    """Close one ticket."""    ...  @plural.mcp.reset(mcp)def start(initial_state: dict) -> None:    TICKETS[:] = initial_state.get("tickets", [])  @plural.mcp.snapshot(mcp)def state() -> dict:    return {"tickets": TICKETS}

Name it in environment.yaml with server: server.py:mcp, and Plural starts a fresh copy for every Trial. @plural.mcp.reset receives the Task's starting state and @plural.mcp.snapshot reports state to Verifiers; the Agent sees neither as a tool. Build an MCP server walks through it, including how a server reads the Environment's and the Task's files.

The other kinds connect a system that already exists:

python
from plural import App crm = App.mcp(    "crm",    url="https://mcp.example.com/v1",    headers={"Authorization": "Bearer ${CRM_TOKEN}"},    credentials=["CRM_TOKEN"],)files = App.mcp("files", server="plural.mcp.files:server")orders = App.openapi("orders", spec="orders.json", base_url="https://sandbox.example.com")

Paths in an App's settings, such as server and spec, are relative to the folder that holds the Environment. Connect an MCP server walks through a hosted server step by step.

A Python App is a class instead of a server. Mark each action with @action:

python
from plural import App, State, action, initial  class Game(State):    secret: str = initial("crane", description="The hidden word.")    solved: bool = False  class Wordle(App[Game]):    """Guess a hidden five-letter word."""     @action    def guess(self, word: str) -> str:        """Guess one five-letter word."""        self.state.solved = word == self.state.secret        return "solved" if self.state.solved else "not yet"     def terminated(self) -> bool:        return self.state.solved

self.state is the App's private state. Fields marked initial() are the ones a Task may set.

Compose Apps into an Environment

An Environment lists its Apps:

python
from plural import Environment, Runtime environment = Environment(name="support-desk", apps=[crm, files, orders], runtime=Runtime.local())

Or, in environment.yaml:

yaml
name: support-deskapps:  - name: desk    mcp:      server: server.py:mcp  - name: crm    mcp:      url: https://mcp.example.com/v1      headers: {Authorization: "Bearer ${CRM_TOKEN}"}      credentials: [CRM_TOKEN]  - name: files    mcp:      server: plural.mcp.files:server  - name: orders    openapi:      spec: orders.json      base_url: https://sandbox.example.comruntime:  provider: local

What the Agent sees

The Agent gets one flat list of tools, built from every App's actions. You don't arrange it; it works the same with one App or ten:

  • Each tool keeps its own name, such as close_ticket or getOrder. Adding an App never renames the tools of the others.
  • Only a clash is prefixed. When two Apps both offer search, they become crm__search and docs__search. So does a tool named like the episode's own reset, step, or finish.
  • Every name works with every model provider. Characters providers reject are replaced, so an MCP tool named salesforce.query is offered as salesforce_query, and long names are shortened to 64 characters. The App is still called with its own name.
  • Each description says which system it acts on when there are several Apps, such as [crm] Close one ticket., and every parameter schema is normalized to one that every provider accepts.
  • Plural's hooks are never tools. reset and snapshot hooks, and a FastMCP server's @plural.mcp.reset and @plural.mcp.snapshot, never reach the Agent.

plural env validate prints the list exactly as the model receives it, and so does the Environment's Apps tab. Offer fewer tools with tools: or exclude: on an App; a model chooses better from a short list of well-described tools.

Apps never call each other. When one App's action should affect another, such as a refund in the orders API closing the ticket in the CRM, subclass Environment and override on_step. It runs after every action and can call any App:

python
from plural import Environment  class SupportDesk(Environment):    def on_step(self, call, result, apps):        if call.app == "orders" and call.action == "refund":            apps["crm"].call("close_ticket", id=call.arguments["ticket"])        return None

What on_step does is not an Agent action and is never shown to the Agent. It changes the world and gives no reward: rewards come from the Task's Verifiers.

Control levels

How far Plural can trust an App's state decides where you can use it. Plural derives each App's control level from its kind and settings; plural env validate prints it.

LevelMeaningExamples
ownedThe state lives inside the Runtime, so every Trial starts fresh.A FastMCP server named with server, Plural's files server, a Python App, an MCP server started with command that keeps its data in ${PLURAL_APP_DIR} and holds no secrets, an OpenAPI App with local: true
sandboxedThe system is remote, but a reset hook returns it to the Task's starting state.A remote MCP server with a reset command that seeds a sandbox tenant; a server marked remote: true with a reset hook
liveThe system is remote and shared, and Plural can't reset it.A remote MCP server or API with no reset hook; a server marked remote: true without one
replayedThe App answers from recordings. Nothing real is called.A replay App

The rules that follow from it:

  • A Benchmark refuses a live App with any action that changes state, because one attempt could change what the next one sees. A live App whose actions only read is allowed, and the Benchmark is then marked not reproducible.
  • A training Job refuses every live App, because its episodes would not start from the Task's state.
  • An action is treated as one that changes state unless it says otherwise: an MCP tool with the readOnlyHint annotation, an OpenAPI GET, HEAD, or OPTIONS, or a Python action marked read_only. For a third-party server, that annotation is the server's own claim, so review its tools before you benchmark a live App.
  • An MCP server started in the Runtime that holds project secrets is taken to reach an outside system, as if marked remote: true. Set remote: false when the secret only unlocks something inside the Runtime, such as a local database.

To use a remote system in a Benchmark, point it at a sandbox and give it a reset hook, which makes it sandboxed.

State, snapshots, and Verifiers

At the start and end of each episode, Plural takes a snapshot of every App: a Python App's state, what a FastMCP server's @plural.mcp.snapshot returns, or whatever an MCP App's snapshot command prints. A Verifier reads them from the episode:

python
from plural import Episode, VerifierOutput  def refunded(episode: Episode) -> VerifierOutput:    crm = episode.apps["crm"]    ticket = crm.query("get_ticket", id="T-7")    done = ticket["status"] == "closed" and crm.final is not None    return VerifierOutput(score=float(done))
  • episode.apps[name].initial and .final are the two snapshots, and .control is the App's control level.
  • episode.state is a shortcut: with one App, its final snapshot; with several, a mapping from App name to final snapshot.
  • query calls one of the App's read-only actions while the Trial's Runtime is still running, so a Verifier can check the real system instead of trusting what the Agent said. Plural refuses an action that changes state.

A Verifier's reward function reads the same Apps after every action, through step.apps, to reward each step. See Reward each step.

Randomness and seeds

Draw random numbers from self.rng rather than the random module, so an episode can be repeated. Each Trial has a seed, set as self.seed before reset, and self.rng is drawn from it:

python
def reset(self, initial_state):    super().reset(initial_state)    self.state.deck = self.rng.sample(CARDS, k=len(CARDS))

In a Job, the seed depends only on the Task and the attempt. Every Agent on attempt 2 of a Task faces the same shuffled deck, a rerun faces it again, and the Trial records its seed. A State that declares a seed field also gets it there, unless the Task's initial State sets one. Outside a Job, pass it yourself: environment.reset(seed=7).

Credentials and secrets

An App never holds a credential's value. It declares the names it needs, and ${NAME} stands for the value in headers and server variables. There are two kinds:

  • Credentials are what the Agent acts with, such as the Salesforce token of the user it works as. The Agent declares which it needs, and each person who runs it saves their own value, so it acts as them, with their permissions. Every Trial records whose credential was used.
  • Secrets come from the project. Use them only for setup: a reset or snapshot command's admin token, or a key a Verifier needs. Those commands never see an Agent's credentials.
python
crm = App.mcp(    "crm",    url="https://mcp.example.com/v1",    headers={"Authorization": "Bearer ${CRM_TOKEN}"},    credentials=["CRM_TOKEN"],    reset="python scripts/seed.py",    secrets=["CRM_ADMIN_TOKEN"],)

The Agent lists the credentials it carries in its agent.yaml, with the scopes each one grants:

yaml
name: sales-botmodel: openai/gpt-5.6credentials:  CRM_TOKEN:    scopes: [read:accounts, write:opportunities]

Save your own value once, from the CLI or under Credentials on your Profile, then run the Agent:

bash
plural credential set CRM_TOKENplural run --task close-deal --agent sales-bot

Before anything starts, the Job checks that the Agent declares every credential the Apps need and that you have a value for each. A run with --model has no Agent to declare any, so it can't use an App that needs one. You can export the value instead of saving it, as CI does.

Values travel only to the process that runs the Apps and to Verifier queries made through it. They are never written to the project or the Trial, shown to the model, or given to its Harness. Each Trial records, for every credential: the App, the Agent and its revision, the declared scopes, where the value came from and whose it was, and a short fingerprint of the value, so you can tell which token acted without storing it. plural trial show prints this record, and so does the Trial page.

An App can name the scopes it needs a credential to grant:

yaml
credentials:  - name: CRM_TOKEN    scopes: [write:opportunities]

A Job then refuses an Agent whose declared scopes for that credential lack any of them. That checks what the Agent's owner declared; the system that issued the token still enforces what it can really do. Run plural env validate to see what each App needs, and plural agent validate to see which credentials an Agent has values for.

Apps hold facts, not rewards

An App reports what happened: its State, its snapshots, and what its actions return. It doesn't judge any of it. Whether an action deserved credit depends on the Task, and often on more than one App, so per-step rewards come from the Task's Verifiers, which can read every App:

python
from plural import Step  def solved(step: Step) -> float:    return 1.0 if step.current_state["solved"] and not step.previous_state["solved"] else 0.0

Name it as the reward of a Verifier the Task lists. See Reward each step.

Apps several Agents share

When an Environment has a party, several Agents act on the same Apps, each from a seat. An App decides whose turn it is with turn(), reads the acting seat as self.seat, and gives each seat its own view with observe(seat). See Several Agents in one Task.

Where to go next