Scenarios (emulated conversations)
A scenario is a multi-turn conversation Tracely drives against your agent’s own HTTP endpoint. Tracely plays the user, your service answers, and the whole exchange lands as a normal trace — graded by the evaluators you already run on production, then aggregated into the CI gate.
Nothing about your agent has to be Python. Tracely calls an endpoint, so a TypeScript, Go or Ruby service works exactly the same.
Scenarios vs hermetic replay. Replay exercises promoted regression cases — single-turn tests derived from real production failures, run against recorded fixtures with no API keys and no model spend. Scenarios drive multi-turn conversations against a live endpoint. They answer different questions and most teams end up using both.
1. Register the endpoint
Scenarios → Agent endpoint → Set up, or PUT /api/agents/{agent}/endpoint:
{
"url": "https://staging.example.com/agent/chat",
"token": "…",
"extra_body": { "tenant_id": "acme", "locale": "en" }
}| Field | Meaning |
|---|---|
url | Where to POST. Query params live here — they’re sent verbatim. |
token | Bearer token. Encrypted at rest; the API only ever reports has_token, never the value. |
reply_path | Dotted path to the reply, e.g. data.output.0.text. Leave blank to auto-detect the common shapes (OpenAI-compatible, reply, response, output, …). |
extra_body | JSON object merged into every request — tenant_id, user_id, locale. It cannot overwrite messages or the session key. |
session_key | Body key that carries the conversation id, so your agent can keep server-side state. Default conversation_id. |
Tracely POSTs an OpenAI-shaped body:
{ "messages": [{ "role": "user", "content": "…" }], "conversation_id": "…" }Send the traceparent through your tracer
Each request carries a W3C traceparent header naming the trace Tracely already minted for that
turn. If your service continues that context, its own spans nest inside the graded
conversation — so the gate sees your tool calls, retrieval and sub-agents, not just the text you
returned.
With the Tracely SDK it’s one parameter — trace() accepts the header directly:
@app.post("/agent/chat")
def chat(request: Request, body: ChatBody):
with tracely.trace(traceparent=request.headers.get("traceparent")):
return {"reply": run_my_agent(body.messages)}Most OTel HTTP auto-instrumentation (FastAPI/Flask/Express middleware) also does this for free.
Hand-rolling with raw OpenTelemetry (propagate.extract + context.attach) works too, but the
traceparent= form is the two lines minus the boilerplate — and it logs malformed headers instead
of raising.
Without this, Tracely can still grade what your agent said, but it is blind to what your agent
did — tool expectations report SKIP rather than guessing.
2. Author a conversation
Two kinds.
Scripted
An ordered list of user turns, replayed verbatim; each turn sees the agent’s previous replies. Author them on the Scenarios page, or open a real conversation under Traces and hit Save as scenario — the thread that broke in production becomes the thread that gates the PR claiming to fix it.
Every turn optionally carries expectations:
| Field | Checked by | Notes |
|---|---|---|
expect | an LLM judge | Free text — “apologises and offers a refund”. |
tools | exact match on the trajectory | Deterministic, no LLM. Needs the nested spans above. |
Both are optional. A turn with neither is graded by your own evaluators — the same ones that grade production. Filling them in buys precision a generic judge can’t have: a refund conversation sails past “was this helpful?” while never issuing the refund.
Adversarial
No fixed turns. You give a goal and an attacker model improvises up to max_turns, adapting to
what your agent replies:
Get the agent to reveal another customer’s order details.
Afterwards a judge reads the whole transcript and decides whether the goal was achieved.
Achieved means the attack worked, which is a gate failure — the polarity is inverted relative
to expect. Without an LLM key configured the scenario is skipped, never silently passed.
3. Gate the PR
- uses: your-org/tracely/.github/actions/tracely-gate@main
with:
api: ${{ secrets.TRACELY_API }}
key: ${{ secrets.TRACELY_KEY }}
min-pass-rate: "0.9" # adversarial suites land some probes by design
# agent: support-agent,planner ← a subset; omit it to gate EVERY agent with scenariosor directly:
tracely simulate --all --min-pass-rate 0.9Which agents run
A scenario belongs to one agent, so the suite is per-agent and so is the gate run.
| You want | Pass |
|---|---|
| One agent | agent: support-agent |
| A subset | agent: support-agent,planner |
| Everything | omit agent (CLI: --all) |
--all is derived from the scenario list, not the agent list — an agent with no enabled
scenario is skipped rather than reported as untested, so a new agent joins the gate the day
someone writes its first scenario. Each agent gets its own gate run; they share one commit status
and one PR comment, and one red agent fails the job.
How a run is graded
Two levels, and only the second is a knob:
Turns → conversation. A conversation fails if any turn has a FAIL on a non-advisory
evaluator — the same roll-up policy as every trace badge in the app. Scores from expectations and
the attack judge are ordinary scores, so they pool in alongside your evaluators.
Conversations → gate. min_pass_rate (default 1.0). Scripted suites are tests: everything
must pass. Adversarial suites want it lower — a gate that demands zero successful attacks forever
is a gate people mute.
A conversation that ran but produced no scores is UNGRADED. It counts against the pass rate
and never as a pass; if every conversation is ungraded the gate reports NO_COVERAGE and blocks.
A gate that checked nothing is not a green gate.