Field Guide · Post-Refactor Overview

Persuasion Agent Benchmark — a pluggable platform for goal-oriented conversation agents

A self-contained harness for A/B-testing goal-oriented conversation engines against a 112-scenario, diversity-stratified benchmark and a reference-aware customer simulator. Once a hardcoded two-engine demo, it is now a platform: register an engine once and it appears in the headless runner, the REST API, and the live dual-panel UI — with the domain framing, the data source, and the benchmark packs all swappable behind clean interfaces.

112 scenarios 3 built-in engines + plugins 3 pluggable seams 32 tests, all green single Engine.produce() contract zero external resources

01 Executive summary

What the platform is and how you use it.

The benchmark drops a candidate sales agent into the middle of a real historical conversation and lets it negotiate, turn by turn, against a calibrated customer simulator until the customer converts (won) or walks (lost). Two reference agents — a single-call Baseline and a planner-driven Planner + Gates — give you something to measure against. You plug in your own engine through a one-method contract and run it head-to-head.

It's a pluggable platform: which algorithm runs, what domain frames the conversation, and where the data comes from are each declared once behind an interface (§2). Engines are declared in a registry that the headless runner, the REST API, and the live dual-panel UI all read from — so a new engine appears everywhere with no extra wiring, and the UI discovers the available options dynamically rather than hardcoding them.

1
place to declare an engine (the registry)
any×any
A/B panel pairings, selected live
3
pluggable seams: engine · domain · data
32
tests passing (no LLM calls required)
◆ The key invariant
The default A/B pairing (LEFT = Baseline, RIGHT = Strategist) and the reference sales domain are behavior-preserving: the default pairing maps one-to-one onto the supervised code path, and the sales domain pack reproduces the original actor prompt byte-for-byte (pinned by a SHA-256 golden test). Swapping engines, domains, or data sources is purely additive.

02 The four pluggable seams

The platform is organized around four small interfaces. Each has one job, one default implementation, several consumers that read from it, and an extension path that needs no core edits.

Everything that varies between experiments lives behind one of these seams: which algorithm produces the agent's turns (the engine registry), what domain defines the framing and the win/lost criteria (domain packs), where the data comes from (scenario sources), and which goal-oriented task is being tested (benchmark packs). The benchmark runner, the REST API, and the live UI are all consumers — they never hardcode the options, they read them from the seam.

SeamModuleJobDefault implRead byExtend without core edits?
Engine registry poc/registry.py One declaration of an engine (id, name, params, factory) is the single source of truth. baseline, planner, strategist · plugins: llamacpp, llamacpp-strategist, llamacpp-planner (local llama.cpp arms) Benchmark runner · /api/engines · UI selectors Yesstrategist.engines entry point
Domain pack poc/domain.py Tenant framing, opp-type coaching, anchor rendering, won/lost signals. SalesDomainPack actor · benchmark outcome check Yes — subclass + register_domain
Scenario source poc/scenario_source.py Where scenarios / opp-meta / transcripts / anchors come from. JsonScenarioSource load_scenarios() · server data layer Yes — implement the protocol + set_scenario_source
Benchmark pack poc/benchmark_packs.py A goal-oriented scenario bundle: manifest (goal, domain) + dataset slice or file. insurance-renewal · ecommerce-cart /api/benchmarks · UI Benchmark selector · load_pack_scenarios() Yes — copy benchmarks/_template/, drop a pack.json

One more capability rides on the registry: the dual-panel server selects an engine per panel, so any registered engine can face any other (§5). The customer it negotiates against is the same on both sides (§6), keeping every comparison single-variable.

03 End-to-end architecture

One contract — async produce(opp_meta, dialog, business_rules) → (text, meta) — drives both run modes.

Engine Registry poc/registry.py — EngineSpec[] in-tree register() + entry-point plugins Dual-panel server + UI GET /api/engines renders L & R selectors Headless Benchmark run_engine("planner") 112-scenario paired A/B Engine.produce() (text, meta) per turn Customer Simulator v2 reference-aware reply Domain pack won / lost signals · framing Scenario source scenarios · opp-meta · anchors Outcome: won / lost + per-turn trace
Figure 1 — The registry feeds both run modes; both call the same Engine.produce(). The engine's text becomes the agent turn; the simulator replies; the active domain pack decides won/lost. Bold blue = the live per-turn path. The supervised "Strategist" native flow is an internal elaboration of the engine box (see §5); not all server scoring sidecars are shown.
models

Two LLMs under the hood

Customer-facing actor → Gemini 2.5 Pro · Simulator + Planner chain-of-thought → Anthropic Sonnet. The benchmark records each engine's meta verbatim and never interprets it.

04 Engines & the connect-your-algorithm API

Declare an engine once; it runs headless, over REST, and in the live UI. This is the path you follow to test a new Strategy/Supervisor algorithm.

The contract — one async method

An engine is any object with a single coroutine. No base class, no inheritance — it's a runtime_checkable Protocol, so structural shape is enough:

async def produce(self, opp_meta: dict, dialog_history: list[dict],
                  business_rules: str = "") -> tuple[str, dict]:
    # returns (customer_facing_text, telemetry_meta)
ParameterShapeMeaning
opp_metadictCustomer profile + scenario context (id, company, 23 profile dims, anchors, voice_profile). Pass through to your stack.
dialog_historylist[{role,text}]role ∈ {"agent","customer"}, chronological. The last customer message is the one to answer.
business_rulesstrTenant compliance text (may be empty).
returns(str, dict)text = the customer-facing reply; meta = free-form telemetry, recorded verbatim, never interpreted (strategy/tone/confidence/gates…).

Stateless per call: the dialogue is passed in every turn — keep no per-session state on self. The benchmark and the live replayer both call exactly this method.

Connect it — three steps

1 · Implement

Write a class with produce(). Wrap an HTTP service, a local model, gRPC — whatever your stack is. See examples/example_engine_template.py.

2 · Register

Declare an EngineSpec (id, name, params, factory) — in-tree via register_engine(), or out-of-tree via a strategist.engines entry point.

3 · Run / compare

Headless: Benchmark.run_engine("myid"). Live: pick it in either panel of the UI and watch it negotiate against another engine.

Code — implement, register, run by id
class MyEngine:
    def __init__(self, temperature=0.7):
        self.temperature = temperature
    async def produce(self, opp_meta, dialog_history, business_rules=""):
        last = next((m["text"] for m in reversed(dialog_history)
                     if m["role"] == "customer"), "")
        text = await my_model.respond(opp_meta, last, temp=self.temperature)
        return text, {"strategy": "reframe", "tone": "warm"}

from poc import register_engine, EngineSpec, ParamSpec, Benchmark, load_scenarios
register_engine(EngineSpec(
    id="myengine", name="My Engine",
    params=(ParamSpec(name="temperature", label="Temperature", type="string", default="0.7"),),
    factory=lambda temperature="0.7": MyEngine(float(temperature)),
))
results = await Benchmark(load_scenarios()).run_engine("myengine", temperature="0.4")

Full walkthrough: docs/PLUGIN_GUIDE.md and docs/API.md.

The registry

Role
Single source of truth for which Strategy/Supervisor engines exist and how to build them.
Type
In-memory registry of EngineSpec dataclasses, populated at import + lazily from entry points.
How built
Built-ins via register(); third-party via the strategist.engines setuptools entry-point group.
In → Out
engine id (+ param values) → an object implementing Engine.produce().
Feeds
Benchmark.run_engine(), the server's /api/engines, and the browser UI's panel selectors.

What an engine declares

FieldMeaningDrives
idStable slug ("planner")API, results dir, panel value
nameDisplay labelUI dropdown + panel title
descriptionOne-linerUI tooltip
runnableBenchmark-instantiable?false for strategist (needs prod DB) → sorts last / flagged
requiresCapability tags ("mysql","lightrag")UI badges
paramsParamSpec[] (enum/bool/string + default)Per-engine UI controls; validated server-side
live_mode"produce" | "native"How the live replayer drives it (generic vs. wired-in)
factory(**params) → EngineInstantiation for benchmark + generic live path
✓ Built-ins registered today
baseline Baseline (Original agent) · planner Planner + Gates (param: planner_envelope ∈ {off,auto,always}) · strategist Strategist (Supervisor) — not benchmark-runnable, requires the production system MySQL + Knowledge Graph. The example echo plugin (below) is discovered only when installed.
In-tree register() EngineSpec(...) Installed plugin strategist.engines EP Registry all_specs() · create(id) UI selectors L & R dropdowns GET /api/engines JSON spec list Benchmark run_engine(id)
Figure 2 — One declaration (solid = in-tree; dashed = auto-discovered plugin) flows to three consumers. Adding an engine never touches the UI, the API handler, or the runner.

Out-of-tree plugins (no core edits)

A third party ships a package exposing a strategist.engines entry point; the registry discovers it the first time it's read. Verified end-to-end: after pip install -e examples/external_engine_plugin, the echo engine appears in all_engine_specs(), /api/engines, and the UI.

Code — declaring & running an engine
# in-tree: declare once
from poc import register_engine, EngineSpec, ParamSpec
register_engine(EngineSpec(
    id="planner", name="Planner + Gates",
    params=(ParamSpec(name="planner_envelope", label="Econ envelope",
                      type="enum", default="off", choices=("off","auto","always")),),
    factory=lambda planner_envelope="off": PlannerEngine(planner_envelope=planner_envelope),
))

# run any engine by id — params validated against its ParamSpecs
bench = Benchmark(load_scenarios())
results = await bench.run_engine("planner", planner_envelope="auto")

# out-of-tree plugin: pyproject.toml
# [project.entry-points."strategist.engines"]
# echo = "strategist_echo:get_engines"
⚠ Dual-import trap (fixed)
The codebase mixes flat imports (import registry, used by the server) and package imports (poc.registry). Left alone, those are two module objects with separate registry/active-domain state — a runtime register on one wouldn't be seen by the other. Fixed by importing the registry, domain, and scenario-source modules under their flat names inside poc/__init__.py so every consumer shares one instance.

05 Any-vs-any A/B in the live UI

Both panels are independent, registry-driven selectors — and the default stays byte-identical.

The dual-panel server runs the same scenario down two panels against one shared customer simulator, so the engine is the only variable. Previously the LEFT panel was hardwired to the baseline and only the RIGHT was switchable (between two hardcoded options, via a planner "bridge" module that was never shipped and silently fell back). Now each panel picks any registered engine, with its parameter controls rendered from the spec.

The trick: gate on the engine, not the panel side

The live replayer had ~9 places gated by panel.side == "right" that actually mean "this is the supervised arm". Those were re-keyed to panel_is_supervised = (engine_id == "strategist"). Because the default pairing is LEFT=baseline / RIGHT=strategist, the boolean evaluates exactly as the old side check — so the default comparison is unchanged by construction, while novel pairings (e.g. Planner on the left) become possible.

POST /api/run engine_left · engine LEFT panel live_mode "native" baseline → actor live_mode "produce" engine.produce() panel_is_supervised = (id == "strategist") → supervisor gates follow the engine RIGHT panel native strategist supervisor chain produce engines planner · plugins same shared customer simulator → engine is the only variable
Figure 3 — Each panel routes by its engine's live_mode: the wired-in "native" flows (baseline, the supervised strategist chain) or the generic produce() path (planner and every plugin). The customer simulator is shared, keeping the comparison single-variable.
▲ Validation honesty
Offline + structural behavior is fully tested, but no live LLM sessions were run (no keys / cost). The default pairing is covered by the byte-identical guarantee; a genuinely novel live pairing (e.g. the supervised Strategist on the LEFT) is enabled structurally but deserves one keyed smoke run before being relied on.

06 The customer simulator

The agent on the other side of every conversation — the thing that makes the benchmark a closed loop rather than a transcript replay.

Role
Plays the customer: reads the dialogue + the engine's latest move and produces the customer's next reply, turn after turn, until the conversation converts or declines.
Type
LLM-driven simulator (poc/customer_simulator.py, class CustomerSimulator), v2 reference-aware mode (default on).
How built
Anchored to a real historical conversation: it sees the customer's actual reply at the current turn as a posture/tone reference, then writes in this customer's extracted voice.
In → Out
reply(dialog_history, live_agent_msg, k)(customer_text, sim_mode).
Shared
One simulator instance faces both A/B panels, so the engine is the only variable across the comparison.

Why a reference-aware simulator

A naive simulator either (a) replays the historical customer — which ignores what the live agent actually said — or (b) free-generates, which drifts off-persona and leaks outcomes (every real-won scenario would tilt "won" regardless of the agent under test). The v2 design threads the needle with two rules:

◆ Same-turn-only reference
At turn k, the simulator may look at the historical customer reply at turn k as a posture/tone anchor — never the future. This is what prevents outcome leakage.
◆ Adapt to the live agent
When the live engine's move diverges from what the historical agent said, the simulator adapts on-topic to the live move while keeping the reference's posture — it doesn't parrot the transcript.
engine.produce() live agent move historical reply @ turn k posture / tone reference extracted voice profile register · decisiveness Simulator customer reply + sim_mode + outcome
Figure 4 — The simulator fuses the live agent's move (solid) with the same-turn historical reference and the customer's extracted voice (dashed, reference-only) to produce the next customer turn. Future turns of the transcript are never visible.

How calibrated

TestSetupResult
Realismheld-out continuation, n=2378% outcome agreement; −22 pp pessimism bias (under-states wins)
Adaptivityperturbed agent, n=28, 2 judges4.71 / 5 mean; 3.6–7.1% echo rate

The −22 pp pessimism bias is a known, documented property: the simulator under-states wins, so absolute win-rates read low — but the relative A/B comparison between engines stays informative.

Dispatch & sim_mode

Each reply is tagged with the mode that produced it, recorded per turn in the results:

sim_modeWhen
generate_v2_refv2 on, a same-turn historical reference exists (the default path)
generate_v2_no_refv2 on, but the live conversation has run past the transcript — no reference available
rephrasev1 hybrid: live move closely matches history → rephrase the historical reply in-voice
generatev1 hybrid: live move diverges → free-generate on-topic
▲ Configuration
POC_SIM_V2_REFERENCE=on (default) selects the reference-aware path; off falls back to the v1 rephrase-vs-generate hybrid (kept for sanity-check comparisons). A per-session hard-customer overlay makes the simulated buyer more skeptical for stress-testing. Outcome detection (won/lost) comes from the active domain pack (§7), so the simulator stays domain-agnostic.

07 Domain packs

Sales stays the reference domain — but as a provider, not an inlined assumption.

Role
Supplies the domain-specific text + outcome logic the engines and scorer need.
Type
DomainPack base class; selected globally via set_active_domain / POC_DOMAIN.
How built
SalesDomainPack holds the original Insurance/Ecommerce strings verbatim and is the default.
In → Out
tenant / opp-type / anchors / customer text → framing strings & won/lost booleans.
Feeds
actor (system prompt) and benchmark (outcome check).

The bundled dataset is, and stays, a sales/insurance corpus — you can't make fixed historical conversations domain-neutral. What was decoupled is the code's coupling: per-tenant business descriptions, opportunity-type coaching, the USD pricing "economic reference frame", and the win/decline detectors all used to be inlined across the core. They now come from the active pack.

Extracted surfaceWas inlined inNow provided by
Per-tenant business / conversion descriptionactor._domain_descdescribe_tenant()
Opportunity-type behavioral coachingactor._opp_type_behavioral_noteopp_type_note()
Economic-anchor section (currency, anti-staircase rule)actor._build_anchor_sectionrender_anchor_section()
Win / decline (won/lost) detectionbenchmark._check_customer_outcomedetect_close()/detect_decline()
✓ Proven byte-identical
The assembled actor system prompt hashes to the same SHA-256 before and after extraction (test_actor_system_prompt_golden, 4245 chars). A companion test (test_domain.py) flips to a custom support-domain pack and asserts the text actually changes, then restores — proving the seam is real in both directions.
Code — a custom domain in ~15 lines
from poc import DomainPack, set_active_domain

class SupportDomain(DomainPack):
    name = "support"
    def describe_tenant(self, tenant):
        return "B2C technical support; resolution = customer confirms the issue is fixed"
    def detect_close(self, text):
        return "that worked" in text.lower() or "resolved" in text.lower()
    def render_anchor_section(self, anchors):
        return ""  # no pricing in a support domain

set_active_domain(SupportDomain())   # actor + benchmark now read from it

08 Scenario / data sources

Where the data comes from is now an interface, not a hardcoded file path.

Role
Read-only access to scenarios, opportunity metadata, transcripts, business rules, anchors.
Type
ScenarioSource protocol; active instance swappable via set_scenario_source.
How built
JsonScenarioSource over the bundled v1_scenarios.json (default); MySQL via poc/db.py.
In → Out
opp_id → opp-meta / messages / anchors; or the full scenario list.
Feeds
load_scenarios() and the live server's data layer.

load_scenarios() keeps its legacy path= argument (direct file read) for full backward compatibility, but with no arguments it routes through the active source — so a custom backend (a different DB, an API, another file format) plugs in with one call. The existing function-style server/db.py shim (JSON ↔ MySQL switch on POC_USE_MYSQL) remains the live server's data layer and mirrors the same field shapes.

◆ Resolution order
load_scenarios(source=…)  →  load_scenarios(path=…) (legacy file read)  →  the active ScenarioSource (bundled JSON by default).

09 The benchmark dataset

112 scenarios, each anchored in a real historical conversation across two industries.

112
scenarios (51 Insurance · 61 Ecommerce)
15
diversity cells (6 insurance · 9 ecommerce)
23
customer profile dimensions
~20%
real-wins (regression sentinels)

Two industries are represented: Insurance (auto-insurance renewal — an existing customer at renewal with a prior premium and likely competitor quotes) and Ecommerce (cart abandonment — a buyer who added an item and walked away). Each scenario is a snapshot of one real opportunity: a 23-dimension customer profile, the full historical transcript, and — where available — the economic anchors (last year's price, market average, internal discount ceiling) that ground the negotiation.

How it's built & used

Diversity stratification

15 cells (6 insurance + 9 ecommerce), each sampled by farthest-point sampling on the 23-dimension profile vector — maximizing spread of customer types rather than sampling uniformly, so a small set still exercises a wide range of buyers.

Embedded transcripts & the seed cut

Every scenario carries its full historical transcript. At run time the benchmark seeds the dialogue up to seed_dialog_cut_idx (the point of live takeover), then the engine drives the rest. Embedding the transcript makes the package self-contained — the simulator's same-turn reference needs no DB at run time (§6).

Regression sentinels

About 20% are real-wins: scenarios that genuinely converted historically. They act as a floor — an engine that loses the easy cases is regressing, independent of how it does on the hard ones.

Anchors (when present)

Per-opportunity economic reference frame. The agent must negotiate from these real figures rather than fabricating market claims; anchor_real:false marks cohort-fallback anchors.

The outcome model

A scenario resolves to won or lost. The customer's reply is checked each turn by the active domain pack: a close/payment signal → won; a decline → lost; a deferral ("let me think about it") keeps the conversation going. If no resolution by max_turns, it's recorded lost with end_reason = max_turns_no_close. Results are paired across arms by scenario_id so engines are compared on identical scenarios.

Scenario schema (abridged)
{
  "scenario_id": "L_Pr_An_Sk_04",        // opaque id (cell-coded)
  "opp_id":      "00733e8d-…",
  "tenant":      "Insurance",            // or "Ecommerce"
  "diversity_bucket": "Price/savings × Analytical × Skeptical",
  "real_outcome": "won",                // historical ground truth
  "is_sentinel":  false,
  "rng_seed":     17004,               // deterministic per-scenario seed
  "seed_dialog_cut_idx": 9,           // live takeover point
  "attributes": { /* 23 dims: primary_motivator, decision_logic, trust_level,
                     communication_style, objection_pattern, purchase_urgency, … */ },
  "anchors": { "last_year_price_usd": 4238, "current_quoted_price_usd": 4581,
              "market_avg_for_segment_usd": 4400, "max_discount_pct_internal": 15 },
  "anchor_real": true,
  "seed_messages": [ /* full historical transcript: direction, text, timestamp, … */ ]
}
Per-scenario result JSON (what a run writes)
{
  "arm": "planner", "scenario_id": "L_Pr_An_Sk_04", "tenant": "Insurance",
  "real_outcome": "won", "outcome": "won", "end_reason": "customer_won",
  "n_live_turns": 3, "elapsed_s": 78.4,
  "turns": [ { "live_turn": 0, "agent_text": "…", "customer_text": "…",
              "sim_mode": "generate_v2_ref", "agent_meta": { /* your verbatim meta */ } } ]
}

Written to {results_dir}/{arm}/{scenario_id}.json; runs are resumable (existing files are skipped). Aggregate across arms with paired_summary(...).

10 Behavior preservation & cross-cutting concerns

How a large refactor stayed safe without live LLM runs.

Golden safety net

Phase 0 captured the domain-coupled behavior offline — actor-prompt hash, anchor section, opp-type notes, outcome detectors, paired-summary math — before any extraction, so any drift fails loudly.

Default-path identity

Supervised gates re-keyed from panel side to engine id; the default L/R pairing maps 1:1 onto the old branches. New behavior is opt-in.

Graceful degradation

Unknown engine ids fall back to defaults; a failing plugin is logged and skipped without breaking discovery; the JSON data shim runs the server with no prod DB.

◆ Removed dead code
The old live "planner" path imported a from engines import planner_produce bridge that was never shipped — it silently fell back to the classic flow, so live Planner never actually ran. Routing through the registry's generic produce() path both deletes that dead branch and makes live Planner work.

11 Testing & verification

32 tests, all green, none requiring an LLM call.

SuiteTestsCoversStatus
test_smoke.py6Imports, bundled-data sanity, protocol check (pre-existing)pass
test_characterization.py11Golden domain behavior (byte-identical pins)pass
test_registry.py8Registry, params, create/raise, run_engine delegationpass
test_domain.py3Domain-pack swap changes actor text + outcomespass
test_scenario_source.py4Default + custom sources, legacy pathpass
✓ End-to-end checks performed
Server boots; /api/engines serves the registry; per-panel runs accept ids + params with bad-engine fallback; and a real pip install -e of the example plugin makes the echo engine auto-appear in the API, the spec list, and produce output with its prefix param.

12 Roadmap — done vs. pending

All six planned phases landed; a few honest follow-ups remain.

Phase 0
Characterization safety net — offline golden tests pinning domain behavior. done
Phase 1
Engine registry + /api/engines + dynamic UI — registry, entry-point discovery, dead bridge removed. done
Phase 2
Any-vs-any A/B — per-panel selection; supervised gates re-keyed on engine id. done
Phase 3
Domain packs — SalesDomainPack extracted byte-for-byte; routed through actor + benchmark. done
Phase 4
Scenario sources — ScenarioSource protocol + JsonScenarioSource default. done
Phase 5
Docs + examples — README/INTEGRATION, installable plugin, custom-domain & registry-driven benchmark scripts. done

13 Repository map

New files marked; the public library lives in poc/.

poc/
 ├─ __init__.py # public API — registry/domain/source via flat imports
 ├─ engine.py # Engine protocol + Baseline/Planner/Strategist
 ├─ registry.py # ★ EngineSpec registry + entry-point discovery
 ├─ domain.py # ★ DomainPack + SalesDomainPack (default)
 ├─ scenario_source.py # ★ ScenarioSource + JsonScenarioSource
 ├─ benchmark.py # runner; run_engine(id) added
 ├─ customer_simulator.py · actor.py # substrate (actor now reads domain pack)
 ├─ planner/ # PCA state-graph planner
 └─ strategist/ # supervisor chain + gates (source for review)
server/ # FastAPI dual-panel UI
 ├─ main.py # + GET /api/engines; per-panel engine validation
 └─ replayer.py # generic produce() path; supervised gates by engine id
client/ # static UI
 ├─ index.html · app.js # dynamic L/R selectors from /api/engines
 └─ style.css
examples/
 ├─ external_engine_plugin/ # ★ installable entry-point plugin (echo)
 ├─ custom_domain_pack.py · run_pluggable_benchmark.py # ★
 └─ example_engine_template.py · run_benchmark.py
tests/ # 32 tests, no LLM calls
 └─ test_characterization · test_registry · test_domain · test_scenario_source # ★

14 Glossary

TermMeaning
EngineAn object with async produce(opp_meta, dialog, business_rules) → (text, meta) — produces one agent turn.
EngineSpecThe registry record describing an engine (id, name, params, factory, live_mode).
live_modeHow the live server drives an engine: "native" (wired-in flow) or "produce" (generic).
DomainPackSupplies domain framing + win/lost signals; SalesDomainPack is the default.
ScenarioSourcePluggable provider of scenarios/opp-meta/transcripts/anchors.
opp_metaCustomer profile + scenario context dict passed to every engine turn.
anchorsPer-opportunity economic reference frame (prices, discount cap, loyalty).
armOne engine's full run over the scenario set; results compared pairwise.
seed cutThe point in the historical transcript where the live agent takes over.
↑ Top