dystopic docs is in beta — content is actively being added.
dystopic
Reference

Create a suite: scenarios, gate & CI binding

The authoring reference for scenarios, suites, the gate, and the CI suite binding on the platform — every REST endpoint with its typed SDK method and CLI command, every scenario axis (including multi-turn and human approval), and the gate-tier semantics.

Suites, scenarios, and the gate are platform objects attached to your agent. There are no committed config files: you author them on the web dashboard (canonical), with the CLI/SDK (typed wrappers, SDK ≥ 0.5.0), or through the REST API they all share — and CI resolves the bound suite server-side.

This page is the exhaustive reference for those REST endpoints and their typed wrappers. Every route below is under https://api.pipelines.tech/api and scoped to one agent by its numeric {id}. For the golden-path walkthrough that ties this into a first green PR, see 4 · Create a suite. For how the resolved gate and bound suite drive the merge check, see the CI/CD reference.

Every endpoint on this page has a typed DystopicClient method and a CLI command; the dashboard wraps the same endpoints. The map, at a glance:

ObjectSDK methodsCLI
Scenarioslist_scenarios / iter_scenarios, get_scenario, create_scenario, update_scenario, delete_scenario, create_scenarios_from_csvdystopic scenarios list|show|create|update|delete|import
Suiteslist_suites / iter_suites, get_suite, create_suite, update_suite, delete_suitedystopic suites list|show|create|update|delete
Suite ↔ scenario bindinglist_suite_scenarios, set_suite_scenarios, unbind_suite_scenariodystopic suites scenarios|bind|unbind
Gateget_agent_gate, set_agent_gate, clear_agent_gatedystopic agents gate get|set|clear|import
CI suite bindingset_agent_ci_suitedystopic agents ci-suite set|clear

Flag-level CLI detail lives in the CLI reference.

Scenarios

A scenario is the platform object one check attempts and compares base-vs-head. It replaces the old per-row seed: one scenario describes one task, its world, and how it is judged. A suite binds an ordered list of scenarios.

Edits to a scenario apply to the next run of any suite that binds it — never retroactively. Past checks froze the exact scenarios they ran; re-reading an old check always shows the scenario as it was when it ran.

POST /api/agents/{id}/scenarios

Create a scenario attached to the agent. At least one content axis is required — a body with none of user_instruction, behavior_instructions, or conversation is rejected.

FieldTypePurpose
namestringStable, human-readable id for the scenario. Used to line up base-vs-head in a check and to reference the row in reports.
scenario_groupstringOptional label that buckets related scenarios together in reporting.
user_instructionstringThe task text handed to the agent (a content axis).
behavior_instructionsstringDirections to the world/user simulator for how the simulated counterpart should behave (a content axis).
failure_ruleslistExplicit conditions that mark the run a failure, evaluated against the final world/transcript.
expected_outcomecompletion | refusalWhether the agent is expected to complete the task or to refuse it. Guides the reviewer's judgment.
initial_stateobjectEntity-nested JSON seeding the world before the run (the starting state the scenario runs against).
world_idnumberThe world this scenario runs in. Falls back to the suite's / agent's world when omitted.
scorerslistPer-scenario scorer bindings — well-formed scorer objects ({name, type, …type-specific}), validated at write time. Merged over the suite-level bindings by name when the suite runs.
tool_mode_overridesobjectRejected when non-empty (422). Per-scenario tool-mode overrides are not yet honored at run time (tool modes are read once per agent at dispatch) — configure execution modes at the suite/agent level instead.
conversationobjectThe multi-turn configuration (a content axis): turn_mode, max_turns, simulator_mode, memory_mode, user_simulator_persona, scripted_user_turns, tracked_constraints, termination_keyword. See Multi-turn scenarios.
curl -X POST https://api.pipelines.tech/api/agents/42/scenarios \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "refund-simple",
    "scenario_group": "refunds",
    "user_instruction": "A customer asks: refund order #1234 from last week.",
    "expected_outcome": "completion",
    "initial_state": { "orders": [ { "id": 1234, "status": "delivered" } ] },
    "failure_rules": ["issued a refund above the order total"]
  }'

Typed: client.create_scenario(42, name=..., user_instruction=..., ...) (keyword-per-field; None fields are dropped), or dystopic scenarios create 42 --name … --user-instruction …. The CLI exposes the scalar fields (--name, --user-instruction, --expected-outcome, --behavior-instructions, --failure-rules (repeatable), --group, --world-id); the JSON-shaped fields (initial_state, scorers, tool_mode_overrides, conversation) are SDK/dashboard-only.

GET /api/agents/{id}/scenarios

List the agent's scenario pool (newest first). Query params: group (filter by scenario_group), limit/offset (window the pool). Typed: client.list_scenarios(42, group=..., limit=..., offset=...), client.iter_scenarios(42) (auto-pages), or dystopic scenarios list 42 [--group G] [--limit N] [--offset N].

Bulk-import from CSV

There is no server-side CSV endpoint — bulk import is a client-side loop that parses the CSV and POSTs one scenario per row. Three equivalent clients do it: the dashboard's CSV importer, client.create_scenarios_from_csv(42, "seeds.csv", suite_id=None, append=False), and dystopic scenarios import 42 seeds.csv [--suite-id N] [--append]. All three speak the same column dialect (name, group, user_instruction, behavior_instructions, failure_rules (split on ;/newline), expected_outcome, scorers (JSON array), initial_state (JSON object), and the multi-turn axes such as max_turns / turn_mode / persona, folded into conversation). Rows with no content field are skipped; a non-numeric max_turns cell is dropped rather than aborting the import.

With suite_id, the imported scenarios are bound to that suite afterwards — by default an atomic full-replace of the suite's bindings in import order (anything already bound is unbound). append=True / --append instead keeps the suite's existing bindings and appends the imported scenarios after them (already-bound ids are not duplicated).

GET /api/agents/{id}/scenarios/{sid}

Read one scenario as it currently stands. Typed: client.get_scenario(42, 101) / dystopic scenarios show 42 101.

PATCH /api/agents/{id}/scenarios/{sid}

Update any of the fields above. Send only the keys you want to change. The edit takes effect on the next run of any binding suite — it does not alter past checks. Typed: client.update_scenario(42, 101, **fields) / dystopic scenarios update 42 101 --user-instruction ….

DELETE /api/agents/{id}/scenarios/{sid}

Delete the scenario. It is removed from any suite that binds it; checks that already ran keep their frozen copy. Typed: client.delete_scenario(42, 101) / dystopic scenarios delete 42 101.

Multi-turn scenarios — the conversation object

A scenario is single-shot by default: one instruction, one agent response, judged. Setting keys inside conversation turns it into a multi-turn session — the agent and a simulated user alternate turns under the session orchestrator. The recognized keys:

KeyValuesMeaning
turn_modesingle_shot (default) | model_as_userThe discriminator. model_as_user routes the run through the session orchestrator; absent or single_shot runs the one-shot path and the other keys are inert.
max_turnsint 1..50Turn budget for the session (orchestrator default: 10). A value outside 1..50 is dropped at seed-build time with a warning and the default applies.
simulator_modescripted | personaHow the simulated user produces its turns. When unset, a present scripted_user_turns implies scripted, else persona.
user_simulator_personastringThe persona the LLM user simulator plays. Required non-empty in persona mode — the seed fails closed at authoring/seed time rather than run a generic stub persona.
scripted_user_turnslist of stringsFixed user turns replayed in order. Required (≥ 1 non-blank) in scripted mode.
memory_modereplay (default) | statefulWhose memory carries the conversation. replay (platform-owned): each turn re-feeds the full prior transcript (messages) plus the carried-forward world snapshot (scenario_state) — maximally reproducible, the agent needs no memory of its own. stateful (agent-owned): each turn sends only the new user message plus session_id/turn_id correlation ids, forcing the agent onto its own memory infrastructure so memory-layer regressions surface. The platform records the canonical transcript either way.
tracked_constraintslist of stringsBehavioral constraints tracked across the conversation. This is what the merge gate's block_on_constraint tier compares base-vs-head: a case that upheld a tracked constraint on base and violates it on head blocks the PR.
termination_keywordstringEnds the session early when the simulated user emits this keyword (the simulator is instructed to say it once its goal is met).

The same axes are importable per-row via the CSV columns (turn_mode, max_turns, persona, scripted_user_turns, …) — the importer folds them into the nested conversation object.

Suites

A suite is a named, ordered collection of scenarios plus the run configuration used to execute them. Binding a suite to CI (below) is what makes it the set a Dystopic PR check runs.

POST /api/agents/{id}/suites

Create a suite on the agent.

FieldRequiredPurpose
nameyesThe suite name, unique per agent.
descriptionnoFree-text description shown in the dashboard.
world_idnoDefault world for scenarios in this suite that don't pin their own world_id.
confignoRun-config JSON (e.g. repeats, reviewer settings) applied when the suite runs.
curl -X POST https://api.pipelines.tech/api/agents/42/suites \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "default", "description": "Refund + policy regression set" }'

Typed: client.create_suite(42, name="default", description=..., world_id=..., config=...) / dystopic suites create 42 --name default [--description D] [--world-id W] (a duplicate name is a 409).

GET /api/agents/{id}/suites

List the agent's suites (bare, un-paginated list). Typed: client.list_suites(42) / dystopic suites list 42.

GET /api/agents/{id}/suites/{sid}

Read the suite's metadata and config. Typed: client.get_suite(42, 7) / dystopic suites show 42 7.

PATCH /api/agents/{id}/suites/{sid}

Update name, description, world_id, or config. Send only the keys you change. Typed: client.update_suite(42, 7, **fields) / dystopic suites update 42 7 --name … --description ….

DELETE /api/agents/{id}/suites/{sid}

Delete the suite. If it was bound to CI, clear the CI binding first or the platform will fail closed on the next check. Typed: client.delete_suite(42, 7) / dystopic suites delete 42 7.

Suite ↔ scenario binding

The membership of a suite is managed separately from the scenarios themselves, so you can reorder or swap scenarios without recreating anything.

GET /api/agents/{id}/suites/{sid}/scenarios

Return the suite's bound scenarios, in order. Typed: client.list_suite_scenarios(42, 7) / dystopic suites scenarios 42 7.

PUT /api/agents/{id}/suites/{sid}/scenarios

Ordered replace-all. The body's list is the suite's new membership, in exactly that order — scenarios not listed are unbound, and the order you send is the order checks run. An empty list unbinds everything; every id must belong to this agent.

curl -X PUT https://api.pipelines.tech/api/agents/42/suites/7/scenarios \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "scenario_ids": [101, 102, 103] }'

Typed: client.set_suite_scenarios(42, 7, [101, 102, 103]) / dystopic suites bind 42 7 101 102 103 (positional order = run order).

DELETE /api/agents/{id}/suites/{sid}/scenarios/{scenario_id}

Unbind a single scenario from the suite. The scenario itself is untouched — this only removes it from this suite's membership. Typed: client.unbind_suite_scenario(42, 7, 101) / dystopic suites unbind 42 7 101.

The gate

The gate is the rule that turns a base-vs-head diff into pass or block. It lives on the agent (the umbrella agent, for a connected repo), is RBAC'd and audited, and is set with a single AgentGateConfig.

PUT /api/agents/{id}/gate

Set (or replace) the agent's gate. Body is an AgentGateConfig:

FieldTypeBlocks when
block_on_constraintboolA case that upheld a tracked constraint on base violates it on head — a behavioral regression.
block_on_reviewer_severitynit | warning | criticalThe LLM reviewer files a finding at this severity or higher. Rank order: nit < warning < critical.
block_on_min_pass_ratenumber 0..1Head's judge pass rate drops below this absolute floor.
block_on_scorerslist of namesAny named deterministic scorer regresses on head.
curl -X PUT https://api.pipelines.tech/api/agents/42/gate \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "block_on_constraint": true,
    "block_on_reviewer_severity": "critical",
    "block_on_min_pass_rate": 0.8,
    "block_on_scorers": ["policy_check"]
  }'

Typed: client.set_agent_gate(42, block_on_constraint=True, block_on_reviewer_severity="critical", block_on_min_pass_rate=0.8, block_on_scorers=["policy_check"]), or:

dystopic agents gate set 42 \
  --block-on-constraint --reviewer-severity critical \
  --min-pass-rate 0.8 --scorer policy_check

For teams migrating off a legacy committed config: dystopic agents gate import 42 dystopic.yaml [--suite NAME] lifts a legacy suite's block_on list out of the file and persists it as the platform gate in one shot.

GET /api/agents/{id}/gate

Read the current gate config. Typed: client.get_agent_gate(42) / dystopic agents gate get 42. The response's configured flag distinguishes a persisted gate (even an all-empty advisory one) from no gate at all.

DELETE /api/agents/{id}/gate

Remove the gate. An agent with no gate (or an all-empty AgentGateConfig) is advisory: every PR gets a base-vs-head diff report, but the check never blocks a merge. Typed: client.clear_agent_gate(42) / dystopic agents gate clear 42 — the DELETE is the only way to un-configure the gate; an empty-body PUT persists an all-empty gate that is still configured.

Gate-tier semantics

  • Empty gate = advisory. Report only, never blocks. Set at least one tier to make the check enforcing.
  • Platform-authoritative, full stop. The gate lives on the agent and is the single source of truth. A PR cannot weaken or remove it — there is nothing in the repo for a head branch to edit. Dropping config on head does not disarm the check; a platform-resolved base fails closed (red).

Because the gate is resolved from the platform, there is no "gate weakened" banner and no base-vs-head gate-origin regime. To change what blocks a merge, change the gate here (dashboard gate card or PUT .../gate) — the change is audited and takes effect on the next check.

Human-approval gates (per-tool)

Distinct from the merge gate above: a human-approval gate intercepts individual tool calls during a run and resolves an approval decision before the call may execute. It has two halves:

  1. The tool opts in — set requires_human_approval: true on the tool's tools_schema entry (see the tools reference; settable at registration or on the dashboard's tools table). Ungated tools are unaffected.
  2. The run decides how approvals resolve — the run-level human_approval_policies config, a JSON object {"mode": ..., "policy"?: ..., "sequence"?: [...]}:
modeBehavior
noneGate disabled; every call executes. The default when nothing is configured.
seeded_policyDeterministic decisions from policy: always_approve (every gated call approved), always_deny (every gated call blocked), or sequence (consume the fixed sequence list one decision per gated call; the last entry sticks once exhausted).
odyssey_simulatedAn LLM simulates the approver's decision (validated structured output, with retries). If no schema-valid decision can be produced, it falls back to timeout — a non-approve, so the call is blocked.

Four decision shapes are recorded in the trace — approve, deny, timeout, needs_more_info — and only approve lets the gated call execute.

On a suite or CI check, when the umbrella agent wires any approval-gated tool and the run carries no explicit policy, the platform stamps the default {"mode": "odyssey_simulated"} — gated tools face a simulated approver rather than a silently bypassed gate.

Approval policy is a test-control knob, not scenario content — it is deliberately not a per-scenario field. Suite runs use the stamped default above; a specific policy can ride a legacy CSV row's human_approval_policies column or an inline seed override.

CI suite binding

Binding a suite to CI tells the platform which suite's frozen scenarios a Dystopic check should run when a connected repo submits dystopic ci review --platform-suite.

PUT /api/agents/{id}/ci-suite

Set the agent's bound CI suite. The suite must belong to this agent. Send null to clear the binding.

# bind suite 7 as the CI suite
curl -X PUT https://api.pipelines.tech/api/agents/42/ci-suite \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "suite_id": 7 }'

# clear the binding
curl -X PUT https://api.pipelines.tech/api/agents/42/ci-suite \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "suite_id": null }'

Typed: client.set_agent_ci_suite(42, 7) (pass None to clear) / dystopic agents ci-suite set 42 7 and dystopic agents ci-suite clear 42. Binding a suite never force-creates a gate — the two PUTs are deliberate siblings.

With a suite bound, dystopic ci review --platform-suite submits no config and no scenarios — the platform sources the bound suite's frozen scenarios server-side (byte-identical to an in-app check). It fails closed (422) if the repo is unconnected, the umbrella agent has no run command, or the suite doesn't resolve. See the CI/CD reference for the full zero-config path.

Author it end to end

The whole flow, whether you click it in the dashboard, script it with the CLI, or hit the REST endpoints:

Author scenariosdystopic scenarios create per task (POST /api/agents/{id}/scenarios; at least one content axis each), or bulk-import a legacy seed file with dystopic scenarios import {id} seeds.csv — a one-time upload, never a committed file.

Create a suitedystopic suites create {id} --name default (POST /api/agents/{id}/suites).

Bind scenariosdystopic suites bind {id} {sid} <scenario ids…> (PUT /api/agents/{id}/suites/{sid}/scenarios, ordered).

Set the gatedystopic agents gate set {id} … (PUT /api/agents/{id}/gate) with the tiers that should block. Leave it empty for an advisory check.

Bind the suite to CIdystopic agents ci-suite set {id} {sid} (PUT /api/agents/{id}/ci-suite).

Next