Skip to main content
Run two simulated people against each other in a chat study: side A versus side B, each with their own scenario and goal. Use it to rehearse a conversation that has not happened yet, a sales rep working a skeptical CTO, a founder facing an investor archetype, a manager preparing a hard one-on-one. The output is a set of transcripts you can read and reason over, not a population finding. This is the participant_pair chat sub-mode: simulated person versus simulated person, with no chatbot endpoint in the loop. To rehearse against a bot you point at, see guides/chat-studies (the external_chatbot sub-mode). For how chat fits the modality registry, see concepts/study.
Cardinality is always two. No three-or-more party group chats, and no marking one side as the live human (live human-versus-AI rehearsal lives in me.ishlabs.io and is not on the MCP or CLI surface yet).

The asymmetry contract

Each side carries its own scenario with its own goal embedded in it, and the partner never sees the other side’s scenario. That asymmetry is the design intent. Without it the two simulated people collude toward whichever objective is more legible and the rehearsal collapses. Write each scenario as if briefing one person who has not spoken to the other.
Do not restate side B’s goal inside side A’s scenario, or vice versa. Cross-leaking the briefs defeats the whole exercise.

How sides pair up

group_a and group_b pair 1:1 by index. Side A’s first person talks to side B’s first person, the second to the second, and so on.
group_a = [tp-aaa, tp-bbb]
group_b = [tp-ccc, tp-ddd]
              |        |
      conversation 0  conversation 1
      (tp-aaa <-> tp-ccc)  (tp-bbb <-> tp-ddd)
Equal counts zip 1:1. One side of exactly one broadcasts to the other: [tp-aaa] against [tp-ccc, tp-ddd] makes two conversations that share tp-aaa. That is the 1xN rehearsal, fix one side and vary the other. Any other mismatch is rejected. You rarely list person IDs by hand. The recommended path is criteria-driven: hand each side a RoleCriteria filter and the backend resolves the pool when the iteration is created. Reach for explicit group_a / group_b only when you already have a hand-curated panel from concepts/people.

Steps

1

Create the study with side-tagged assignments

A pair study persists two Assignment rows, one per role. Each carries the role label (name), the scenario (instructions), an optional goal (test_context), and the side discriminator (a or b). This is the same shape the product writes, so a study created here opens correctly in the launcher.chat_mode is required when modality is chat. Set it to participant_pair.
study_create(
    workspace_id="w-6ec",
    name="Sales rep vs skeptical CTO",
    modality="chat",
    chat_mode="participant_pair",
    assignments=[
        Assignment(
            side="a",
            name="Sales rep",
            instructions="You're a sales rep pitching an analytics SaaS to a CTO.",
            test_context="Secure a 30-day pilot.",
        ),
        Assignment(
            side="b",
            name="CTO",
            instructions="You're a CTO evaluating new analytics tools.",
            test_context="Stay within budget; avoid lock-in.",
        ),
    ],
)
On the MCP path, do not pass iteration content to study_create. Pair iterations are deferred to study_add_iteration so you can pick the audience selection first. The CLI can build iteration A inline when you pass the pair flags to ish study create (shown below), or you can keep the two steps separate with ish iteration create.
2

Scout pool sizes before committing (recommended)

person_get speaks the same demographic vocabulary as RoleCriteria, so the cheapest pre-flight is a list call with limit=10. The envelope carries total (the full match count) plus a small sample to eyeball. If either side returns total: 0, mint the missing pool with person_generate before you create the iteration.
person_get(
    workspace_id="w-6ec",
    occupation=["cto", "engineering manager"],
    country=["US"],
    education_level_in=["bachelor", "graduate"],
    limit=10,
)
# -> PaginatedList(total=23, items=[Person, ...], ...)
RoleCriteria accepts occupation, age band, gender, country, five enum filters (education_level_in, household_in, locale_type_in, income_level_in, employment_status_in), and five coarse accessibility booleans (requires_captions, uses_screen_reader, prefers_reduced_motion, prefers_high_contrast, has_any_accessibility_need). The full enum values are in the tools-study reference.
3

Create the pair iteration, criteria-driven

Hand each side a RoleCriteria filter and let the backend resolve the pool at create time. You never need to know which person IDs exist. Echo the scenarios here so the run-time path has them; the goal stays on the Assignment.test_context you set in step 1. initiator_side decides who speaks turn 0 (default a).
study_add_iteration(
    study_id="s-b2c",
    name="Iteration 1",
    modality="chat",
    chat_pair=ChatPairConfig(
        role_criteria_a=RoleCriteria(
            occupation=["sales rep", "account executive"],
            country=["US"],
        ),
        role_criteria_b=RoleCriteria(
            occupation=["cto", "engineering manager"],
            country=["US"],
            education_level_in=["bachelor", "graduate"],
        ),
        scenario_a="You're a sales rep pitching an analytics SaaS to a CTO. Goal: secure a 30-day pilot.",
        scenario_b="You're a CTO evaluating new analytics tools. Goal: stay within budget; avoid lock-in.",
        initiator_side="a",
    ),
    max_turns=14,
)
Each side requires either a non-empty RoleCriteria or an explicit audience. An empty filter on one side is rejected up front rather than accepted and failed at run time. Scenarios must be non-empty on both sides.
4

Or pin an explicit panel

Use explicit audiences when you already have specific participants in mind, for example a hand-curated panel from person_get. Pass person IDs or tp- aliases for each side.
study_add_iteration(
    study_id="s-b2c",
    name="Iteration 1",
    modality="chat",
    chat_pair=ChatPairConfig(
        group_a=["tp-aaa"],
        group_b=["tp-ccc", "tp-ddd"],
        scenario_a="You're a sales rep pitching an analytics SaaS to a CTO. Goal: secure a 30-day pilot.",
        scenario_b="You're a CTO evaluating new analytics tools. Goal: stay within budget; avoid lock-in.",
    ),
    max_turns=14,
)
Side A here is one person broadcast against two on side B: two conversations that share the sales rep, varying the CTO. Passing both criteria and an explicit audience is allowed; the backend then validates the explicit list against the criteria. Once the iteration exists the resolved audiences are baked in. You do not pass them again at run time.
5

Dispatch the run

Pair iterations carry both rosters, so study_run needs only the study and iteration. The audience argument is rejected here (the audience lives in the iteration). config_id is optional: omit it and study_run auto-infers from the first group_a person’s simulation_config_id. If that person has no config assigned, the dispatch returns a validation_error naming the missing field rather than silently picking one; pass config_id explicitly or assign a config to the source person.
study_run(study_id="s-b2c", iteration_id="i-d4e", wait=False)
Both sides consume one model call per turn, so the cost is roughly the per-turn chat cost times two per pair. See concepts/credits-and-limits for the model. Prefer the default non-blocking dispatch and poll the returned hint; the study_run reference covers wait, timeout, and the client transport ceiling. For the run-versus-ask choice, see concepts/run-vs-ask.
6

Read the transcripts

Pair runs return one transcript per conversation, not per participant. Each transcript interleaves both sides’ turns by timestamp and carries a conversation_summary with who_steered, dominant_dynamic, momentum_shifts, and a summary prose field. The end_reason is stamped on the conversation itself and surfaced alongside the summary, not inside the summary blob.
study_get(study_id="s-b2c", view="transcripts")
The MCP view="transcripts" returns every conversation in one call. The CLI --transcript projection takes one participant; to slice the aggregate by role, filter with --side a or --side b. For how reactions and signals are structured, see concepts/reactions-and-results.

End reasons

Each conversation ends for one of these reasons, surfaced on end_reason:
ValueMeaning
MAX_TURNSHit the per-iteration turn cap.
MUTUAL_DISENGAGEBoth sides’ last turn reported they did not want to continue.
MUTUAL_GOALBoth sides’ last turn reported their goal was achieved.
MIXED_ENDOne side achieved its goal; the other disengaged.
NO_PROGRESSAn action cycle was detected; the conversation looped.
ERRORA model or worker failure.

The two chat modes side by side

external_chatbotparticipant_pair
Who talks to whomsimulated person versus your chatbot endpointsimulated person (side A) versus simulated person (side B)
Iteration inputendpoint or chatbot_endpoint_idchat_pair=ChatPairConfig(...)
Cost per turnone model calltwo model calls
Transcript granularityone per participantone per conversation (both sides)
Audience selectionstudy_run(audience=...)baked into the iteration; audience rejected at run time

Reference

study tools

study_create, study_add_iteration, study_run, study_get, and the ChatPairConfig / RoleCriteria / Assignment shapes.

ish study create

Every flag for the CLI study create, including the pair-mode and inline-iteration shortcuts.

ish study run

Dispatch flags, config override, and pair-mode behavior.

ish study results

Transcript projection and the --side / --turn slice flags.

Probe a chatbot

The external_chatbot mode: endpoint setup, smoke tests, slot bindings.

People

How audiences and criteria resolve to the simulated people who play each side.

Iteration

The iteration shape and where mode_details lives.

Reactions and results

How transcripts, summaries, and signals are structured.