Actions
An action is a move the Agent can make. In Wordle, guess is an action. At a support desk, lookup_order or refund might be. Every action belongs to an App: an MCP App offers its server's tools, whether you wrote the server with FastMCP or connected an existing one, an OpenAPI App offers its operations, and a Python App declares its actions with @action.
Why it matters
Because Apps own the actions, every Agent gets exactly the same moves, whichever model or Harness it uses. That keeps comparisons fair. It also keeps the system in control: the Agent only ever learns what an action returns.
How it works
Each time the Agent takes an action:
- Plural sends the call to the App that owns it. The action can change the App's hidden State.
- Whatever the action returns is the observation, the one thing the Agent is shown next.
- If the Environment has an
on_stephook, it runs, and may call other Apps. The Agent doesn't see that. - Each of the Task's Verifiers with a
rewardfunction rewards the action. The Agent doesn't see that either.
In Python
Mark each method with @action. The docstring is the description the Agent reads, and the typed parameters become the action's arguments:
from plural import App, State, action class Orders(State): lookups: int = 0 class OrderDesk(App[Orders]): @action(read_only=True) def lookup_order(self, order_id: str) -> str: """Look up an order by id.""" self.state.lookups += 1 return f"Order {order_id} has shipped."Here, lookup_order counts the lookup in the hidden State, which the Agent never sees, and returns the order's status, which it does.
What an action may return:
- A string is shown as is.
- A dict, list, or Pydantic model is shown as JSON.
- An
Observationis shown through itsrender()method, so you control the text exactly. Noneshows nothing.
@action takes two options:
read_only=Truesays the action never changes state. Verifiers may onlyqueryread-only actions, and aliveApp with only read-only actions can still be used in a Benchmark.timeout_secondslimits how long one call may run. The default is 30.
Errors
When an action raises ValueError, the Agent sees Error: followed by the message, and the episode continues. Use it for feedback the Agent can act on:
from plural import App, State, action class Game(State): secret: str = "crane" class Wordle(App[Game]): @action def guess(self, word: str) -> str: """Guess one five-letter word.""" if len(word) != 5: raise ValueError("Guess exactly five letters.") return "solved" if word == self.state.secret else "not yet"Any other exception is treated as a bug in the App and fails the Trial.
The episode
- At the start, Plural calls each App's
reset(initial_state). Whateverresetreturns is the Agent's first observation, along with the Task's instructions.resetis not an action. - Each action is one
environment.stepon the Trial, with the App, the action, the arguments, and the observation. - The episode ends when an App's
terminated()returns true, when a limit in the Environment'slimitsis reached, or when the Agent replies without calling an action. Each has its own stop reason on the Trial.
Under the hood, a step returns observation, reward, terminated, truncated, info. Only the observation reaches the Agent. Rewards are recorded for training and never contribute to a score.
Actions and Harness tools
Every Harness receives the Environment's actions. Plural's native loop offers them to the model as tools, plus a finish tool. Each tool has the action's name, such as guess, made safe for every model provider. Only when two Apps offer the same name is it prefixed with the App, such as orders__search. See What the Agent sees.
A Harness may add tools of its own, such as web search. The Environment can deny those but never adds them. A denied Harness tool returns an error to the model and the Trial continues. Nothing a Harness reports can add a score, a reward, or new actions.
See Harness and Environment policy and Trials and trajectories.