Skip to content

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. RoundWorld when it meters a per-round budget, plain ScenarioWorld when 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.inbox and renders it as JSON with pending_count and current_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 with wake.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 await in 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 None to make such a call wait until a notification arrives. Do this when you use ends_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 True for 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 is all_agents_finished when every agent waits for the next round, and all_agents_waiting otherwise. 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:

@classmethod
def scenario_metrics(cls) -> Sequence[type[Metric]]:
    return (QueueDepthMetric,)

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:

make gen-api-types

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 v1 in 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-data entry covers.
  • Discovery still applies: empty __init__.py, events.py on event_base only. 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 .env sits beside your pyproject.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.

glossogen serve --runs-dir ./runs --port 8000 --ui-port 3000

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:

  1. The log ends with Simulation complete. Run directory: runs/your_scenario/<timestamp>.
  2. The JSONL holds your <Scenario>CaseStarted event once per round and one RoundResultRecorded per round (per team per round when multi-team).
  3. 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.py is the only place agents, channels and rosters are named. get_agents() / get_channels() delegate to team_structure.
  • [ ] Every role's starts_as_member is decided, not defaulted. A role that arrives mid-run is False.
  • [ ] The world's on_message override calls up, and round_budget_thresholds is ordered most severe first.
  • [ ] postmortem_channel_ids lists 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, with seed = 42 and the canonical judge.
  • [ ] Prompts live in prompts/*.jinja, not in Python strings.
  • [ ] judge_round_result returns at least one RoundResult per round, and get_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-types re-run.
  • [ ] make lint is clean. Regenerate the vulture whitelist if Pydantic fields or auto-discovered classes get flagged.
  • [ ] One end-to-end smoke run completes and round_success returns a non-empty per-round list.

Common pitfalls

  • ImportError naming your scenario at platform startup. Check that __init__.py is empty and events.py does not import glossogen.models.event.
  • A late-arriving agent saw the traffic it was meant to arrive after. Check starts_as_member on 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-server checks, 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.ts is generated; CI fails on drift. Run make gen-api-types after 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-root scripts/.

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