Creating a scenario¶
A scenario is one self-contained N-agent coordination task. This page is the reference for every file in a scenario package and every extension surface, in the order you meet them when editing a generated package into your own scenario.
A scenario owns:
- A team declaration — the roles, the comm topology, who can read and send what, stated as data the engine derives the run from.
- Tools — the tools each agent can call beyond the platform's
read_notifications/read_channel/send_message. - A world — the live environment that emits notifications, validates tool
results, tracks state, and decides round success.
RoundWorldwhen it meters a per-round budget, plainScenarioWorldwhen it meters nothing. - Knobs and presets — the Pydantic-validated configuration the CLI and frontend expose.
- Per-round injections — the round-start text each agent receives, rendered from Jinja templates.
- Optional surfaces — scenario-specific metrics, run-detail data on the API, and a frontend plug-in, all opt-in.
Events, run-detail extensions and frontend plug-ins are discovered automatically.
Start from the generator¶
Generate a package rather than assembling one:
glossogen new-scenario reactor_purge --target-dir .
cd reactor-purge
glossogen validate .
pip install -e ".[testing]"
pytest
What you get already runs: two agents relay a code word over a metered link,
validate passes, the tests pass, and glossogen run completes. Editing one
thing at a time and watching what breaks is a faster way through this contract
than writing it from scratch, and the generated README lists what to change in
which order. (Working inside a glossogen checkout, glossogen itself is already in
the environment: install the package with uv pip install -e . --no-deps, and
re-run that line after any make install-*, since uv sync removes anything the
lockfile does not name.)
The package:
reactor-purge/
├── pyproject.toml # entry point + package-data
├── tests/ # the scripted round loop; see Testing a scenario
└── reactor_purge/
├── __init__.py # stays empty (below)
├── ids.py # agent ids, channel ids, tool names, markers
├── knobs.py # Pydantic knobs extending BaseKnobs
├── knobs_default.json # the canonical preset
├── events.py # EventBase subclasses, auto-discovered
├── team_declaration.py # teams, roles, channels, as data
├── world.py # live state + reactions
├── scenario.py # the SimulationScenario contract
└── prompts/ # every word an agent or judge reads
The package __init__.py stays empty, and events.py imports only
glossogen.models.event_base. Event
discovery imports your events module while glossogen.models.event is itself
mid-import, so anything that pulls the wider module in closes that cycle and the
platform fails at startup. validate checks both.
The files, and what to change in each¶
Nothing enforces this split. It is convention, and every scenario in the tree follows it.
| File | Owns | Does not belong here |
|---|---|---|
ids.py |
Every literal string used more than once: agent and channel ids, role names, tool names, world marker strings | Anything with behaviour. If it needs an if, it belongs elsewhere |
knobs.py |
Scenario-specific fields, plus validators rejecting impossible combinations. seed belongs here: BaseKnobs has none |
Anything BaseKnobs already declares; redeclaring one shadows the platform's |
knobs_default.json |
The canonical preset, the values "by default" means | Experiment presets, which are separate knobs_*.json files |
events.py |
The scenario's own event types | Imports from glossogen.models.event |
team_declaration.py |
The teams, and for each its task channel, debrief policy and roles | Prompt text, and anything that varies within a round |
world.py |
State that changes during a round, and the reactions to it | Anything read only at round boundaries |
scenario.py |
The platform contract: injections, tools, scoring. Structure it derives from the declaration | Mutable per-round state (world's), prose (prompts'), rosters (the declaration's) |
prompts/ |
Every word an agent or a judge reads | Nothing. No prompt text in Python |
ids.py¶
Centralize the strings, including the world's marker strings: a fixed prefix on
tool results and WorldEventDelivered.text so an agent reading one can tell the
outcome classes apart. Round scoring does not read markers. round_success reads
the RoundResultRecorded events the game clock writes from judge_round_result.
container_yard_stacking/ids.py
is a worked example.
knobs.py and the presets¶
Extend BaseKnobs with only your scenario's fields, all required, values in the
presets: a knob with a default is one a preset can omit, and then the run's
recorded config does not say what it ran with.
class WarehouseRobotRecoveryKnobs(BaseKnobs):
judge_model: str
judge_provider: Provider
judge_model_settings: JudgeModelSettings = JudgeModelSettings()
round_time_budget_seconds: int
seed: int
fault_count_min: int
fault_count_max: int
@model_validator(mode="after")
def _validate_fault_count_bounds(self) -> "WarehouseRobotRecoveryKnobs":
if self.fault_count_min < 1:
raise ValueError(f"fault_count_min must be >= 1 (got {self.fault_count_min})")
if self.fault_count_max < self.fault_count_min:
raise ValueError("fault_count_max must be >= fault_count_min")
return self
That is
the real one.
BaseKnobs already carries round_count, max_round_duration_seconds,
model_overrides, scheduled_events and the other shared fields. A key the model does not declare is refused
when a run is configured, so a misspelled knob in a preset or a key=value
override is an error rather than a silent fallback to the default; a config a
run recorded is read through create_from_recorded_config, which drops and logs
a knob you have since removed.
In knobs_default.json, every BaseKnobs field without a default has to be
present (model_overrides is {} when there are none), and the conventions are
seed: 42 and judge_model: "claude-haiku-4-5-20251001" /
judge_provider: "anthropic" for a scenario that has a judge.
A scenario that judges its rounds with an LLM declares judge_model,
judge_provider and judge_model_settings, as above (judge_model_settings
keeps its default, which sends nothing). Those three fields give the scenario
self.round_judge: the judge they name, built on its first call, whose spend
ScenarioBase.judge_costs bills to the run. Hand it to whatever calls the judge
(a tool executor, say). A scenario scoring its rounds deterministically declares
none of them.
events.py¶
One EventBase subclass per scenario event, each with a unique event_type
literal. The discovered-union parser picks them up. You never edit
glossogen/models/event.py.
from typing import Literal
from glossogen.models.event_base import EventBase
class YardCaseStarted(EventBase):
event_type: Literal["yard_case_started"] = "yard_case_started"
case_number: int
incoming_container: str
team_declaration.py¶
Export one function taking your knobs and returning tuple[TeamSpec, ...], one
entry per isolated group of agents. The engine meters, judges and reports each
team independently.
team_declaration.py defines the
types. team_structure.py turns
them into what the platform asks for.
Field on TeamSpec |
States |
|---|---|
task |
The TaskChannel the team works on. Being the task channel is what makes it metered, corrupted under channel noise, and shut during debrief |
debrief |
Debrief(...) or NoDebrief(). A team with no post-round discussion says so |
roles |
One RoleSpec per agent: id, role name, system-prompt template, tool_names, and the two booleans below |
No field has a default, so omitting one is a type error rather than a silent
behaviour. tool_names is the list of tools the agent is offered, and it is not
additive: a role that talks lists send_message, and an empty list connects an
agent that can do nothing. Names are the platform's five plus whatever
get_tools() returns, and validate rejects a name no tool answers to.
The two booleans answer different questions. joins_debrief is membership:
whether this role belongs to the team's debrief channel at all, since a team
can hold a debrief that not every member attends. starts_as_member is timing:
whether the role sits in its channels' rosters from round one, or a scenario
event seats it later.
A late-arriving role such as
veyru's intern is
declared with starts_as_member: false: its channels are configured from the
start, which is what shapes its system prompt, but it starts reading them only
when its join fires. Declared a member from round one instead, it would read
the traffic from before its arrival, and nothing would fail: the run completes
while the experiment quietly measures the wrong thing.
container_yard_stacking's
intern is seated from round one on purpose, with a comment saying so.
A scenario can also skip the declaration entirely.
prisoners_dilemma
has no team_declaration.py: its two opponents share one channel with no
budget, which is not a team in any useful sense, so it builds its agents
directly in scenario.py. Every other shipped scenario declares.
world.py¶
Subclass RoundWorld
(round_world.py) when the scenario
meters a per-round budget, which almost all do. Plain ScenarioWorld
(scenario_world.py) only when it
meters nothing. The world mutates state synchronously in on_message, reacts
asynchronously in on_message_async (pushing notifications through
context.send_update_to_channel), and holds the state the round verdict reads.
The base class owns the run loop. Override the hooks, never run.
RoundWorld provides:
| Provided | Contract |
|---|---|
| Per-team character metering | Override on_message, call up so the engine accumulates, read characters_used(team_id=...) and apply your budget rule. On a shared task channel the sender's team pays |
| Budget announcements | claim_round_budget_threshold answers whether an announcement is still owed this round. Declare round_budget_thresholds most severe first: claiming one claims every milder one, so a team is not told its budget is low just after being told it is gone |
| Round history | RoundOutcomeLog stores one record per finished round per team, so the debrief injection and the next boundary agree |
Take the specs and channel ids as constructor arguments rather than importing the ids, so the world and the declaration cannot disagree about which channels exist. Postmortem messages cost no budget, and the postmortem can be globally disabled mid-run. The shapes to mimic: WarehouseWorld for a single tool, ContainerYardWorld for sequenced multi-tool state.
scenario.py¶
The SimulationScenario subclass is what the registry hands the CLI, the MCP
start_run tool and the run-detail UI. The full contract is
scenario_protocol.py. The base class
derives the getters (knobs_json_schema, get_round_count,
get_scenario_config, ...) from knobs_model() and get_knobs().
| Member | Does |
|---|---|
name(), scenario_description() |
The registry key; a one-line description read from prompts/description.jinja |
knobs_model(), get_knobs(), create_from_config(config) |
Your knobs class; the held instance; the validating factory |
get_agent_roles(knobs) |
The (agent_id, role_name) pairs preflight validates model overrides against. Takes the run's prepared config, or None for the baseline roster; read knob values by validating the dict through cls.knobs_model(), or one boolean flag with cls.resolve_bool_knob(...) |
get_agents(), get_channels() |
Delegations to team_structure.build_agent_configs(...) and team_structure.channels(...), never hand-written lists. You supply the render_system_prompt callback |
get_world(), get_tools() |
Construct the world from the same specs; one ScenarioTool per scenario tool. An executor takes agent_id first, which the runner supplies for the calling agent; its other parameters, with their annotations, are the tool's schema. It refuses a call by raising ValueError, and the agent reads its message as the tool's error; any other exception reaches the agent only as Error executing tool <name> |
get_injection(round_number, agent_id) |
The round-start Jinja injection, or None for an agent with nothing to hear. Case and previous outcome come from your world |
get_postmortem_injection(...) |
Same shape, for the debrief phase |
on_round_advanced(round_number) |
Resolve the previous round, load the next case, and log your <Scenario>CaseStarted event via self.runtime.event_logger |
on_round_ended(round_number, trigger) |
Settle round-end state. trigger is a RoundEndTrigger value when the clock ended the round, or your own early-end string; compare against the enum's members |
validate_outgoing_message(...), transform_outgoing_message(...) |
Enforce and mutate messages: budget refusal, noise injection |
get_early_round_end_trigger() |
Optional: a trigger string when the round should end before the clock. It must not spell a RoundEndTrigger value; the clock raises on one |
restore_state_from_events(events) |
Optional: seed per-round outcomes after a fork or resume, so the first post-resume injection renders accurate "previous result" context |
Three members need more than a row.
judge_round_result(round_number, trigger) is how the scenario declares who
won a round. It is required, runs when the round ends, and returns one
RoundResult(success, team_id, reason) per team, with team_id=None for a
single-team scenario. The game clock records each result as a
RoundResultRecorded event, and the round_success metric reads those back.
How the verdict is reached is up to the scenario:
prisoners_dilemma computes it from its payoff matrix, veyru asks an LLM judge.
get_primary_channels() is required and names the conversations the
language and throughput metrics score. The unit is one entry per conversation,
not one per team. Two teams talking on separate links are two conversations:
two entries, each carrying its team_id, and the metrics report
perplexity_team_a and perplexity_team_b. Two teams sharing one link are one
conversation: a single entry with team_id=None, scored as a pool.
spot_the_difference
runs either way, switched by a knob.
postmortem_channel_ids is a ClassVar[frozenset[str]] listing every
channel that could carry postmortem traffic under any knob setting, not only the
ones the current config enables. It is a class attribute rather than a knob
because its readers ask about the scenario, not about one run's configuration:
the replaced-agent history filter strips these channels out of a reconstructed
history, and the mid-run set_postmortem shutdown closes them, whether or not
the run at hand has a postmortem. Hand the same set to the world as
type(self).postmortem_channel_ids instead of importing the channel ids a
second time, so the two cannot drift apart.
prompts/¶
Every prompt is a Jinja2 template. description.jinja, one <role>_system.jinja
and one <role>_injection.jinja per role, postmortem_injection.jinja when the
scenario debriefs, and one template per LLM judge.
Rendering is strict: a name the template uses but the Python never passes raises
UndefinedError instead of resolving empty, so a misspelling fails at render
rather than producing a prompt missing a number or a whole {% if %} block. Pass
every name the template mentions, including the ones only a condition reads;
None is a value you chose, so {% if previous_outcome %} works on round one.
validate renders every round's injections against every preset.
Optional surfaces¶
Post-simulation hooks¶
Implement one to opt into the matching platform metric. The default answer makes the metric skip with no Measurement.
| Hook | Opts into |
|---|---|
build_communication_rounds(events) |
communication_open_coding, communication_feature_presence; each view joins one round's messages with a scenario-rendered ground-truth block |
detect_protocol_boundary_window(events, agent_configs) |
protocol_learned_after_swap. Default: the first AgentSwappedMidRun. Override for scenario-specific boundaries and fall back to super() |
get_protocol_probe_config() |
The protocol_probe metric family. Ship the question bank and probe templates in your package; veyru/scenario.py is the canonical wiring |
get_protocol_explanation_config() |
Per-role describe templates for protocol_explanation, which otherwise runs with a generic prompt |
get_judge_models(knobs) |
The launch check that refuses a run whose environment cannot reach your judge, before a round is spent. Default: the judge_model / judge_provider pair. Override when the judge is conditional or you call more models of your own |
get_replace_agent_blocked_tool_call_channels() |
Channels stripped from a replaced agent's reconstructed history. Defaults to postmortem_channel_ids, which is usually all you need |
How agents wait¶
An agent ends its turn with read_notifications. The call parks the agent until
its wait is satisfied,
and no model request is made while it is parked. wait_for chooses the condition:
wait_for |
Resumes on | Without timeout_s |
|---|---|---|
any (default) |
the next notification of any kind | no_activity after 120 seconds |
message |
a teammate's message, or the next injection | no deadline |
next_round |
the next injection: the next round's briefing, or a postmortem briefing | no deadline |
The end of the run resumes every kind. A call issued alongside other tool calls
does not park: the other calls run, and this one answers no_activity. Each wait
logs wait_registered and agent_resumed.
Four hooks on SimulationScenario shape the wait. Each has a default; override
only the ones your scenario needs.
read_notifications(agent_id, wake) renders the string the agent receives
when its call resumes. wake carries why the agent resumed (wake.reasons), what
it waited for, how long it waited, whether the run is over for it
(wake.terminated), and wake.inbox, its queued notifications.
- Default: takes the oldest notification from
wake.inboxand renders it as JSON withpending_countandcurrent_round. - Override when the agent should see more than one notification at a time. A
typical rendering drains message bodies with
self.runtime.drain_unread_channel_messages(agent_id), takes briefings and the end-of-run notice withwake.inbox.take_lifecycle(), and adds a world observation, so the agent needs no further call to see what changed.take_lifecycle()leaves new-message notices and read positions alone; the drain is what marks messages read. - Do the draining and the taking before the first
awaitin your override. A message that arrives while the rendering awaits is then still unread, and goes with the agent's next result instead of being lost.
read_notifications_description() returns the tool description agents read.
Override it whenever your rendering or your default_any_wait_timeout_s changes
what the call returns, so the description matches.
default_any_wait_timeout_s() returns how long a read_notifications() call
with wait_for="any" (the default kind) and no timeout_s waits before answering
no_activity.
- Default: 120 seconds.
- Return
Noneto make such a call wait until a notification arrives. Do this when you useends_round_when_all_agents_waiting, which does not count an agent whose wait has a deadline as parked for good.
ends_round_when_all_agents_waiting() returns whether a round's main phase ends
as soon as every agent is parked with no deadline.
- Default:
False. Rounds end on the idle check after a quiet period, or on the wall-clock timeout. - Return
Truefor a world that changes only through agents' actions: once every agent is parked with no deadline, nothing can change until the timeout, so the round ends at once. The trigger isall_agents_finishedwhen every agent waits for the next round, andall_agents_waitingotherwise. Postmortem phases are not ended this way.
Releasing a parked agent. A team whose every member waits for a teammate's
message is deadlocked: nobody writes first. Ending the round is one answer
(ends_round_when_all_agents_waiting); telling an agent so is another.
on_agent_parked(agent_id) runs inside the parking call, before anything else can
resume the agent, so a scenario can read self.runtime.parked_waits() there, see
every running agent (self.runtime.running_agent_ids()) parked on a message
wait, and resume one with self.runtime.release_wait(agent_id, detail). The
agent's read_notifications returns with released among its reasons; the
default rendering answers no_activity with detail as its text, and an override
reads wake.release_detail.
def on_agent_parked(self, agent_id: str) -> None:
parked = self.runtime.parked_waits()
if all(
agent in parked and parked[agent].wait_for is WaitFor.MESSAGE
for agent in self.runtime.running_agent_ids()
):
self.runtime.release_wait(
agent_id=agent_id, detail="Every teammate is waiting for a message; act."
)
Scenario tools¶
get_tools() returns one ScenarioTool per action agents can take on the world,
and a role lists the tool's name in its tool_names to be offered it. The runner
builds the tool in-process for each agent: the executor's first parameter is
agent_id, which the runner fills in for the calling agent, and its remaining
parameters, with their type annotations, are the schema the model sees. The
executor refuses a call by raising ValueError; the agent reads the message as
the tool's error and can correct itself. Any other exception is logged and
reaches the agent only as Error executing tool <name>.
def get_tools(self) -> list[ScenarioTool]:
async def stabilize(agent_id: str, action: str) -> str:
if agent_id != self._world.observer_id:
raise ValueError("Only the field observer can stabilize.")
return await self._world.stabilize(action=action)
return [
ScenarioTool(
name="stabilize",
description="Describe exactly what you are doing to stabilize it.",
executor=stabilize,
)
]
glossogen validate checks that every executor takes agent_id first and that
its other parameters are annotated, and that every name in a role's tool_names
is a base tool or one of these.
A scenario whose agents should choose each action after seeing the result of the
one before sets "agent_model_settings": {"parallel_tool_calls": false} in its
presets: the runner then asks the provider for at most one tool call per model
response. Without it, a model can act and commit in a single response, before
anything another agent does can reach it. signal_game does this for that reason.
Replacing send_message¶
send_message_executor() returns the function behind the send_message tool. Its
parameters, other than agent_id, are the tool's input schema, and each must
carry a type annotation; glossogen validate checks. The platform passes
agent_id from the calling connection. It returns a model extending SendReceipt,
as SendMessageResult does, so the recorded result carries the status and
message_id that metrics and history reconstruction read. send_message_description() is the
description agents read. hidden_base_tools(agent_id) withholds base tools from an
agent: they are left out of its tool list and refused if called, which suits a
scenario that delivers messages inside tool results and so has no use for
read_channel. read_notifications cannot be hidden.
A replacement posts through self.runtime.publish_message(agent_id, channel_id,
text, force), which applies validate_outgoing_message and
transform_outgoing_message, logs the message and notifies the other members;
force=True skips the unread-messages check. self.runtime.direct_channel_for(agent_id,
recipient_agent_ids) returns the channel whose members are exactly the sender and
those recipients: a primary channel with those members, or else
dm:<sorted ids joined by +>, created the first time and logged as
channel_created. Direct channels are opt-in:
validate_direct_channel(agent_id, recipient_agent_ids) returns the reason a
request is refused, or None to allow it, and the default refuses every request.
Override it to allow direct channels, and return a reason for the pairings your
scenario forbids, such as an agent on the other team or a role that takes no
direct messages; the runtime checks on its own that every recipient is another
agent in the simulation. Direct channels are listed by list_channels, readable
with read_channel unless the scenario hides it, restored on fork and resume, and
shown in the run viewer. A primary channel with includes_direct_channels=True
scores the direct channels whose members all belong to it.
def send_message_executor(self) -> SendMessageExecutor:
return self.send_to
async def send_to(self, agent_id: str, text: str, to: list[str] | None) -> SendMessageResult:
channel_id = TEAM_CHANNEL_ID
if to is not None:
channel_id = await self.runtime.direct_channel_for(
agent_id=agent_id, recipient_agent_ids=to
)
return await self.runtime.publish_message(
agent_id=agent_id, channel_id=channel_id, text=text, force=True
)
Replacing the runner prompts¶
runner_prompts(agent_id) returns a RunnerPrompts or None. None, the
default, keeps the platform's. A replacement sets the suffix appended to the
agent's system prompt (system_suffix), the user prompt that opens its first cycle
(initial) and the one that opens every later cycle (continuation). Render them
from your own prompts/ templates. The registration records a replacement, so
fork, resume, probes and exports render what the run used.
Simulated time¶
A scenario that orders agents by simulated time, rather than by when a model
server answered, implements these hooks. schedule_wait_timeout and clock_now_s
default to the wall clock; the rest default to doing nothing.
| Hook | Called |
|---|---|
on_model_request_started(agent_id) |
As the runner issues a model request |
gate_model_response(agent_id, input_tokens, output_tokens) |
Awaited after a response arrives and before any of its tool calls run. Hold the response here until its turn |
schedule_wait_timeout(agent_id, timeout_s, fire) |
When a parked agent has a deadline. Return a cancel. The default counts wall-clock seconds |
clock_now_s() |
Whenever a wait starts or resumes, so waited_seconds is measured on the scenario's clock. The default reads the wall clock |
on_agent_parked(agent_id) / on_agent_resumed(agent_id) |
As an agent parks in read_notifications and resumes. A wait satisfied at registration does neither |
on_agent_retired(agent_id) / on_agent_enlisted(agent_id) |
As a runner returns, and as a swapped-in runner starts |
on_simulation_stopping() |
Before agents are told the run is over. Release every response gate_model_response still holds, or the run waits for the wall-clock limit |
textcraft_shared_workspace is the worked example of the waiting, messaging, prompt and simulated-time surfaces above.
evaluation/¶
Most scoring is scenario-agnostic, and get_primary_channels() being required
means the language and throughput metrics come free. Add a metric of your own only
for a domain signal that reduces to neither round success nor language phenomena,
and see Creating a metric before you do: a generic metric
reading a hook is preferred.
A metric of your own is declared by the scenario class, wherever its module lives:
It is then offered and runnable for this scenario's runs and no other's, alongside
the generic metrics. Its name must not repeat a generic metric's; validate
checks. signal_game is the
example.
Run-detail extension¶
For per-round ground truth, judge metadata keyed by tool call_id, or custom SSE
events on the run-detail API. The scenario class returns the extension:
@classmethod
def run_detail_extension(cls) -> ScenarioRunDetailExtension:
return VeyruRunDetailExtension()
The contract is
scenario_run_detail_extension.py
and the canonical example
veyru/run_detail_extension.py,
though the module can be named anything. Declare an extras model whose
scenario_name is a Literal of your scenario's name (validate checks it), a
ScenarioRunDetailExtension subclass naming that model, and a build_extras that
walks the events. Then regenerate the frontend types so the discriminated union
picks up your variant:
Frontend plug-in¶
For a custom round-detail panel, tool-verdict rendering, live-judge wiring,
timeline markers, or the chip for a read_notifications result your
read_notifications hook renders, ship a ScenarioPlugin at
frontend/src/features/runs/<your_scenario>/plugin.tsx and register it in
scenario-registry.ts. The
contract is
scenario-plugin.ts, the
canonical example
veyru/plugin.tsx, and every
slot is optional. A round-detail panel receives the round's round_results
beside your extras, so the extras need not repeat each team's verdict. Plug-ins
are compiled into the bundle, so this surface exists only for a scenario living
in this repo. An unknown name resolves to the default plug-in rather than
failing. Without a plug-in, the run viewer still splits a
read_notifications result from its call and wires the two together, and
renders a payload whose type the platform does not define field by field.
In this repo, or in your own package¶
In this repo: the package lives at src/glossogen/scenarios/<name>/ and
registration is one line in
scenario_registry.py. That is the only
file outside your package you touch.
In your own package: registration is the entry point the generator already
declared. The key is the name callers pass to glossogen run and the API, the
value the module and class:
[project.entry-points."glossogen.scenarios.v1"]
reactor_purge = "my_scenarios.reactor_purge.scenario:ReactorPurgeScenario"
What differs from an in-repo scenario:
- A name a built-in already holds stays with the built-in, and the collision is logged: a run's config records only the scenario name, so a redefinition would make old runs irreproducible.
- The
v1in the group is the contract version. A platform speaking a different version does not read your group, and says so by name. scenario_api.py explains why the version lives in the group. - Presets and prompts ship in your package, which is what the
package-dataentry covers. - Discovery still applies: empty
__init__.py,events.pyonevent_baseonly. A broken events module in an installed package is logged and skipped rather than taking the platform down. - No frontend plug-in. The platform UI renders everything it derives from your knobs schema and event log.
- Configuration is read from your project: the
.envsits beside yourpyproject.toml. See Configure environment.
Launching is the same either way: glossogen run, or the MCP start_run tool.
Viewing your runs in the web UI¶
Start the server from the environment your package is installed in, since the environment decides which scenarios resolve: a server started from a glossogen checkout knows only the scenarios that checkout ships, so your run would list under a name it cannot build.
The flags, the --ui-image pin and the checkout alternative are covered in
Web UI.
Check it, then smoke it¶
glossogen validate <dir> while editing, glossogen validate <name> once
installed. It builds every preset, checks the contract and the package, reports
every failure rather than the first, and needs no API key, so it belongs in your
CI. Testing a scenario has the full check table, and the
scripted round loop (run_rounds) that exercises what validate cannot: the
world's state machine, the postmortem phase, and the round verdict, with no LLM
and no waiting.
Then one short real run, end to end:
glossogen run your_scenario \
--model claude-haiku-4-5-20251001 --provider anthropic --runs-dir ./runs \
--config knobs_default \
round_count=3 \
> ./runs/your_scenario_smoke.log 2>&1 &
Pass criteria:
- The log ends with
Simulation complete. Run directory: runs/your_scenario/<timestamp>. - The JSONL holds your
<Scenario>CaseStartedevent once per round and oneRoundResultRecordedper round (per team per round when multi-team). - With a run-detail extension, the round-timeline modal shows your data under
make dev+make dev-frontend.
Then evaluate it:
glossogen evaluate your_scenario \
--run-dir ./runs/your_scenario/<timestamp> \
--metrics round_success,mean_chars_per_round,mean_chars_per_message \
--model claude-haiku-4-5-20251001 --provider anthropic
The report should hold one Measurement per metric with sensible score and
per_round values.
Pre-flight checklist¶
- [ ]
glossogen validate <dir>passes (or<name>, once installed). - [ ]
team_declaration.pyis the only place agents, channels and rosters are named.get_agents()/get_channels()delegate toteam_structure. - [ ] Every role's
starts_as_memberis decided, not defaulted. A role that arrives mid-run isFalse. - [ ] The world's
on_messageoverride calls up, andround_budget_thresholdsis ordered most severe first. - [ ]
postmortem_channel_idslists the debrief channels of every mode the scenario can run in, the default preset's and the rest. - [ ] Every knobs field is required. Preset values live in
knobs_default.json, withseed = 42and the canonical judge. - [ ] Prompts live in
prompts/*.jinja, not in Python strings. - [ ]
judge_round_resultreturns at least oneRoundResultper round, andget_primary_channels()returns one entry per independently metered channel (teams sharing a link get one pooled entry, not one each). - [ ] With a run-detail extension:
make gen-api-typesre-run. - [ ]
make lintis clean. Regenerate the vulture whitelist if Pydantic fields or auto-discovered classes get flagged. - [ ] One end-to-end smoke run completes and
round_successreturns a non-empty per-round list.
Common pitfalls¶
ImportErrornaming your scenario at platform startup. Check that__init__.pyis empty andevents.pydoes not importglossogen.models.event.- A late-arriving agent saw the traffic it was meant to arrive after. Check
starts_as_memberon that role. The run completes and only the experiment's meaning changed. - Vulture flags scenario classes as unused. Pydantic fields and
auto-discovered classes look unused. Regenerate the whitelist over the same
paths
make lint-serverchecks, or the new file drops the entries covering the paths it left out:VIRTUAL_ENV= uv run --no-sync vulture src/ scripts/ linter/ --min-confidence 60 --make-whitelist > vulture_whitelist.py - Frontend types out of sync.
frontend/src/types/api.gen.tsis generated; CI fails on drift. Runmake gen-api-typesafter any schema change. - Script placement. One-offs importing your scenario live under the
scenario's own
scripts/. Only cross-scenario tools live in the repo-rootscripts/.
Reference scenarios¶
| Scenario | Read it for |
|---|---|
| container_yard_stacking | The cleanest layout to mirror: deterministic scoring with no judge anywhere, sequenced multi-call actions validated by the world |
| warehouse_robot_recovery | The simplest single-judged-action pattern, with a per-character budget |
| satellite_contact_window | Sequenced command submission judged in one call against an authorization envelope |
| veyru | Every optional extension surface at once: run-detail extension, frontend plug-in, per-scenario scripts, probe wiring |