0048 — Iterate and promote the live voice agent via ElevenLabs branches, not agent clones
- Status: Proposed
- Date: 2026-06-04
- Deciders: Fede (primary executor and approver). Gera may execute non-mutating steps (branching, sims, evals, reading conversations) but may not run any live-traffic mutation (steps 4–7 of the core loop, Phases 2–5) until his access is provisioned and Fede signs off — see Phase 0.
- Related: ADR-0028 (tool catalog / ID-based tool model), ADR-0029 (role prompts + tool router), ADR-0040 (identity-anchored router), ADR-0042 (inbound routing / capability continuity). Voice latency fan-out rollout context:
project_voice_latency_fanout_rollout. - Scope: ElevenLabs Conversational AI for Camellia (LIVE leasing line
+18445101007, specialist agentagent_8601, reached via the shared Triage agent throughtransfer_to_agent) and the Triage → specialist topology. Test Propertyappfolio-45on+18442853526.
This ADR is both an architecture decision and a runnable playbook. A new engineer should be able to follow §Roadmap top-to-bottom and ship a change to the live voice agent without ever regressing Camellia traffic. Every step that mutates production is gated by a human approval and an eval-green check.
Context
Camellia's leasing voice agent is LIVE. Real prospects call +18445101007 and reach the shared Triage agent, which routes by transfer_to_agent to specialist siblings (Leasing agent_8601, Maintenance, Renewal, Resident Services). The live leasing path is therefore Triage → transfer_to_agent → agent_8601 — almost no prospect dials the specialist directly. The hard constraint is non-negotiable: never regress live Camellia traffic. A bad prompt edit, a dropped system tool, or a mis-pointed webhook is not a localhost bug — it is a missed lead or a falsely-confirmed tour on a real resident's call.
Earlier in this work-stream we tried to get a safe sandbox by cloning the agent (make a copy, edit the copy, point a test number at it, promote by hand-copying config back). Cloning failed in specific, reproducible ways (see §Pitfalls), all of which trace to one root cause: a clone is a fresh agent that does not inherit the original's tool bindings, override toggles, or phone wiring, so you rebuild those by hand and get them subtly wrong.
Meanwhile, ElevenLabs ships a native, git-style iteration model against the live REST API (https://api.elevenlabs.io/v1/convai). What is verified read-only (route existence + read shapes, confirmed 2026-06-04): branch list/read (GET /agents/{id}/branches → results[].current_live_percentage), branch create, branch edit without touching Main, and the merge route is path-style POST /agents/{id}/branches/{source_branch_id}/merge. What is NOT yet executed (documented, must be confirmed before any live write): the deployment/traffic-split write call and its percentage unit, and the merge call's body/behavior. (See §Flagged uncertainties — an earlier draft of this ADR mislabeled the merge shape and the traffic-read endpoint; corrected 2026-06-04.)
- Every agent has a Main branch (
agtbrch_*) at 100% traffic and an immutable version (agtvrsn_*). Versioning/branches are on. - You can create a branch off a Main version (starts at 0% traffic, isolated), edit the branch without touching Main (
PATCH /agents/{id}?branch_id=...— proven: editing the branch'sfirst_messageleft Main'sfirst_messageunchanged), deploy a traffic split (deterministic routing byconversation_id), and merge to promote (mints a new Main version and moves traffic over). - The codebase already supports branch routing: the personalization webhook returns a
branch_id, andELEVENLABS_STAGING_BRANCH_PHONESroutes one engineer's phone to a staging branch on the live number while everyone else stays on Main.ELEVENLABS_STAGING_BRANCH_NUMBERSroutes whole test lines. - Environment variables resolve per-environment tool URLs / secrets / auth at conversation time, so the same agent + tool config hits dev backends in test and prod backends in prod — no config edits at promotion.
A branch inherits everything (system tools, webhook tool IDs, the enable_conversation_initiation_client_data_from_webhook override toggle, phone wiring) and Main is untouched until merge. That is strictly better than cloning. This ADR makes branches the default and demotes cloning to a fallback.
This decision also formalizes two patterns we learned the hard way and want to keep enforcing as we iterate:
- A testing ladder (sim → deterministic replay → real call) where each rung catches a different class of failure, so "the text sim passed" is never mistaken for "the voice agent is safe."
- A collect-live / enrich-post-call architecture (the maintenance pattern, generalized to leasing): do the minimum live, defer the rest to a post-call transcript-extraction step — which shrinks the live blast radius of every branch we promote.
The Triage-transfer routing question (must be resolved before Phase 3)
Because the live path is Triage → transfer_to_agent → agent_8601, a deployment split posted on agent_8601 only matters if transfer-routed calls actually get split-routed to the branch. Two open questions, both UNVERIFIED and explicitly gated in Phase 0:
- Does
transfer_to_agentresolve the deployment split at transfer time (so a caller transferred into Leasing can land on the branch), or does it always pin to Main? If transfers always hit Main, a canary onagent_8601measures zero real Camellia leasing traffic and we would promote blind. - Does the personalization-webhook
branch_id(engineer-phone routing) apply to the specialist after a Triage transfer, or only when a number dials the specialist directly?
We do not assert either behavior. Phase 0 includes a hands-on probe (place a Triage-transferred test call routed to the branch, then confirm the resulting agent_8601 conversation carries the branch's branch_id). If transfers do not honor the split/branch, the canary plan changes (e.g. split must be posted on the Triage agent, or the test must dial the specialist directly with that caveat documented). No Phase 3 ramp may start until this is resolved.
✅ RESOLVED (2026-06-05, from the ElevenLabs docs — supersedes the two UNVERIFIED questions above).
transfer_to_agentruns the CHILD agent's own live config ("all other configurations are set by the child agent, including Prompt, LLM, tools…" — Personalization), and branch traffic is split deterministically byconversation_id(Versioning). The transferred call keeps itsconversation_id, so a deployment split posted onagent_8601IS resolved for transfer-routed calls — a Leasing canary is exercised by real Triage→Leasing traffic. (This is inferred by composing the two documented facts; still worth a one-call Phase-0 probe to confirm hands-on, but the canary architecture is sound, not blind.) The transfer carries dynamic variables, not the branch — only the child's own deployment decides which branch runs.- The personalization-webhook
branch_iddoes NOT work — at all, not just post-transfer.branch_idis not a field EL honors inconversation_initiation_client_data; it is silently ignored. TheELEVENLABS_STAGING_BRANCH_PHONES/_NUMBERSengineer-phone routing is therefore a no-op and must be dropped.Net effect on this ADR: the traffic-split canary on
agent_8601(toolkit steps 5-6) is the correct, supported path and needs no Triage-side workaround. Drop the engineer-phone branch-routing wherever it appears below (toolkit step 4 / testing-ladder rung 3 "route your phone to the branch", line ~23/54): substitute dial the specialist's DID directly (+18442853526→ Leasing, as of 2026-06-05) or a small traffic-split %. See the companion playbook §4 for the full corrected mechanics.
Decision
We develop and promote voice-agent changes using ElevenLabs native branches + experiments + versioning + environment variables. Cloning an agent is deprecated to a fallback, used only when an agent has versioning disabled.
A process or code change violates this ADR if it does any of the following:
| ❌ Violation | ✅ Required instead |
|---|---|
Edits agent_8601 Main config directly to "try something" |
Create a branch off Main; edit ?branch_id=; Main stays live |
| Clones the agent to get a sandbox | Branch off Main (clone only if versioning is disabled on that agent) |
| Points the live Camellia number at a candidate config for everyone | Route only an engineer's dedicated-test caller_id (ELEVENLABS_STAGING_BRANCH_PHONES) or a small traffic % via a deployment |
| Promotes on a green text sim alone | Pass the eval gate and a real voice/latency call before any live traffic |
| Promotes without the eval suite green | run-tests green (intent-capture, never-confirm-on-error, no_prospect regression) is the promotion gate |
| Hard-codes a prod tool URL/secret into branch config so testing writes to prod | Use environment variables; run tests/calls under environment=staging |
| Runs a "staging" call before the staging env-var gate (Phase 0) is green | Prove every side-effecting tool has a non-prod staging value first — else "staging" silently writes to prod |
| Ramps traffic past a stage without a human "go" and a defined sample floor | Per-PR / per-stage human approval and the sample floor in §Canary exit criteria |
| Lets a regression sit while debugging mid-incident | Throw the kill switch when any hard rollback trigger fires (§Rollback triggers), then debug on the branch |
| Runs a second canary while another branch is mid-ramp | One active canary at a time (§Concurrency); read /agents/{id}/branches (results[].current_live_percentage; /deployments is 405) before posting any split |
Bypasses a failing pre-push hook with --no-verify |
Surface the failing test; fix-first / disclose / abandon — never silently bypass |
(a) The branch-based isolation pattern (the core loop)
One agent_id, no cloning, no analytics fragmentation. Three primitives:
| Primitive | ID prefix | What it is | Mutability |
|---|---|---|---|
| Version | agtvrsn_* |
Immutable config snapshot, auto-created on every save | Immutable forever (rollback target) |
| Branch | agtbrch_* |
Named isolated line of versions; every agent has a permanent Main | Mutable pointer |
| Deployment | — | Percentage traffic split across branches (must total 100% across all branches) | Reassignable instantly |
The loop (each step is an explicit REST call). Every deployment write uses the full two-element array summing to 100 — never a single-item array (a lone [{branch, 5}] either errors or implicitly zeroes Main and blackholes 95% of callers):
| Step | Call | Effect on Main / live traffic |
|---|---|---|
| 1. Branch off Main | POST /agents/{id}/branches {parent_version_id, name, description} |
None — new branch at 0% traffic, isolated |
| 2. Edit the branch | PATCH /agents/{id}?branch_id={b} (conversation_config / workflow / platform_settings) |
None — proven: branch first_message edit left Main unchanged |
| 3. Read back | GET /agents/{id}?branch_id={b} (include_draft=true for uncommitted) |
None |
| 4. Route engineer's phone | personalization webhook returns branch_id via ELEVENLABS_STAGING_BRANCH_PHONES |
None — only the listed (dedicated-test) caller_id hits the branch; everyone else → Main |
| 5. Canary traffic | POST /agents/{id}/deployments [{branch_id: <main>, percentage: 95}, {branch_id: <b>, percentage: 5}] — always include the Main entry; the two must sum to 100. ⚠️ Write endpoint UNVERIFIED — GET …/deployments returns 405, so the POST write shape/path is not confirmed; pin it on a non-prod agent in Phase 0 before any live split. |
Small % of callers routed to branch; deterministic per conversation_id (same caller, same branch) |
| 6. Roll back | POST /agents/{id}/deployments [{branch_id: <main>, percentage: 100}] (same UNVERIFIED-write caveat as row 5) |
Re-deploy Main 100% (propagation latency to be measured — Phase 0); or serve any prior version_id |
| 7. Promote (merge) | POST /agents/{id}/branches/{source_branch_id}/merge {target_branch_id=main, archive_source_branch} (path-style — route existence verified read-only 2026-06-04; body/behavior not yet executed) |
Mints new Main version, moves traffic to Main, optionally archives source |
| — Read traffic split | GET /agents/{id}/branches → results[].current_live_percentage (verified 2026-06-04). Use this to confirm "Main at 100%" — not GET /deployments, which returns 405. |
Read-only |
Percentage unit (MUST be pinned in Phase 0 before any deployment write). The roadmap writes whole-number percentages (5, 10, 25, 50, 90, 95, 100). It is UNVERIFIED whether the field is a whole number (50 = 50%) or a fraction (0.5 = 50%). If it is a fraction, percentage: 50 means 5000% and the API either rejects it or — worse — clamps/normalizes and mis-splits live traffic. Phase 0 pins this with a 0%-impact probe (post a split to the branch at 0 and Main at the max, read it back, confirm the echoed unit). Until pinned, no Phase 3+ deployment may run. All percentages in this ADR assume the whole-number unit; if the probe shows fractions, every value below is divided by 100 before use.
Isolation checklist (must all hold before step 5):
- Branch was forked from the current live Main version (
parent_version_id= live Main). - Branch edits were made via
?branch_id=— never via a barePATCH /agents/{id}. - A
GET /agents/{id}(nobranch_id) confirms Main config is byte-for-byte what it was before. - Side-effecting tools resolve to staging backends via environment variables under
environment=staging(proven by the Phase 0 staging-env gate, not assumed). - No other branch is mid-canary —
GET /agents/{id}/branches(readresults[].current_live_percentage;GET /deploymentsis 405) shows Main at 100% before you post a new split (§Concurrency).
(b) The testing ladder (each rung answers a different question)
Do not skip rungs. A green sim is necessary but not sufficient — it cannot see voice re-fires or personalization.
| Rung | Mechanism | Question it answers | What it MISSES |
|---|---|---|---|
| 1. Text simulation | POST /agents/{id}/simulate-conversation (tool-mocked, read-only, fast) |
Tool count, intent firing, prompt behavior, eval criteria, false confirmations | Voice re-fires (e.g. save_prospect ×3), personalization |
| 2. Deterministic replay | Webhook replay, per-tool | Rigorous per-tool latency | Turn-taking, ASR/TTS, barge-in |
| 3. Real test call | Route your dedicated-test phone to the branch (step 4 above), then pull the EL conversation (GET /conversations/{conversation_id}) |
Personalization + voice turn-taking (only rung that does); real tool sequence, per-tool latency, results, termination_reason |
— (this is ground truth) |
Pulling a conversation (used in rung 3 and Phases 2–4). "Pull the EL conversation" means GET /v1/convai/conversations/{conversation_id} (list via GET /v1/convai/conversations?agent_id={id}&branch_id={b} to find the id). Read the per-turn tool calls, results, and termination_reason from the returned transcript. The per-tool latency field name (tool_latency_secs) is asserted from prior reads, not formally verified in the OpenAPI — confirm the exact field on the first real pull and fix references if it differs.
Rules baked into the ladder:
- Robot wall-clock is not a latency metric. Per-tool latency comes from the pulled EL conversation, not from a stopwatch around a script.
- Voice-channel re-fires (e.g.
save_prospect×3) do not appear in the text sim. A clean rung-1 run does not clear a change for live traffic. - Tool mocking must default to "mock everything." If
MockNoMatchBehavior=call_real_toolandsimulation_environmentdefaults to production, an unmatched tool call fires a real SMS/booking/DB write. For leasing/maintenance, setMockingStrategy=allorMockNoMatchBehavior=raise_error.
(c) The eval framework = the promotion gate
No branch reaches live traffic (step 5) until the saved eval suite is green. Built on the EL native testing stack: simulate-conversation + Tests API (type: llm / tool [verify_absence] / simulation with tool_mock_config) + run-tests with agent_config_override (test a modified prompt without mutating the agent).
| Eval | Type | Pins | Pass condition |
|---|---|---|---|
| Intent capture — clear intent | simulation / tool |
Clear leasing intent ⇒ save_prospect fires |
save_prospect called once on clear intent |
| Intent capture — wrong number | tool [verify_absence] |
"wrong number" ⇒ no capture | save_prospect absent |
| Never-confirm-on-error | llm / simulation |
On a tool error (e.g. schedule_tour → no_prospect) the agent must NOT confirm |
Transcript contains no tour confirmation when the tool errored |
no_prospect regression |
simulation + tool |
schedule_tour self-resolves/creates its prospect |
schedule_tour does not return no_prospect; no false escalation |
| Triage transfer reaches Leasing | simulation |
A leasing intent into Triage ⇒ transfer_to_agent to agent_8601 |
Transfer fires; the candidate branch is exercised on the real live path, not the specialist in isolation |
Gate rules:
- Treat
unknownas "not passing" in all gating logic — never as success. - Every real failure becomes a committed eval case seeded from the real call (
from_conversation_metadata). Every bug fix ships a regression test that trips the original bug — "the existing tests still pass" does not count. run-testsis async — pollGET /test-invocations/{id}untilbucketing_status=completed; gate on everycondition_result.result === "success".- LLM-as-judge is non-deterministic — anchor with
success_examples/failure_examples; resubmit flakes; never--no-verifypast a hook. - Verify tool bindings by canonical tool ID, not by name before trusting any eval result.
(d) Collect-live / enrich-post-call architecture
Shrink the live blast radius so each promoted branch can do less harm. Rule for what stays live:
A step stays live only if it needs real-time confirmation (a booking) OR comes from a volatile external source (tour slots from the property's Outlook calendar). Everything else is deferred to a post-call transcript-extraction step.
| Domain | Live (minimum) | Post-call (enrich/discard) |
|---|---|---|
| Maintenance | create_work_order collect-only |
maybeCreateWorkOrderFromTranscript |
| Leasing | ONE optimistic save_prospect on clear intent (phone + property is enough → lead captured + a prospect exists for schedule_tour); check_availability; schedule_tour. Units are init-injected (no get_available_units). No live update_prospect. |
Prospect enriched or discarded from the transcript after the call |
Correctness invariants this encodes (each from a real failure):
schedule_tourself-resolves/creates its prospect —save_prospectandschedule_tourmust not use different resolvers (that mismatch caused a falseno_prospectescalation).- On any tool error, the prompt NEVER confirms the action (a no-live-save variant returned
no_prospect100% and the agent falsely confirmed the tour — both are bugs). schedule_tourwrites a real slot to the property's Outlook calendar and needs real-time confirmation — so the prod booking path is never first exercised on a real prospect. It is validated by the Phase 2.5 controlled prod-booking dry run (engineer caller, prod env, a designated test unit/slot) before any canary.
Pitfalls — EL config gotchas, and why branches avoid most of them
| Pitfall | What goes wrong | Why a branch avoids it |
|---|---|---|
both_tools_and_tool_ids_provided |
A request sends both prompt.tools (deprecated) and prompt.tool_ids → rejected. A cloned agent that holds both system tools and webhook tool_ids trips this. |
A branch inherits the original's exact ID-based bindings; you don't re-author them, so you can't reintroduce the legacy field. |
| Lost system tools | A clone drops the system tools (end_call, transfer_to_agent, …) that live in built_in_tools. |
A branch carries built_in_tools forward unchanged. |
| 1008 crash: "Missing required dynamic variables in tools" | A bare clone crashes because the enable_conversation_initiation_client_data_from_webhook override toggle wasn't copied. |
A branch is expected to inherit the override toggle and the whole platform_settings — confirm by reading the branch config (Phase 0 toggle check) before relying on it. |
| Repointing the test phone number | A clone is a new agent_id, so you must re-wire a DID to it. |
You route a caller_id to the branch via the webhook on the existing live number — no re-wiring. |
Destructive platform_settings PATCH |
A wholesale platform_settings PATCH can clobber per-agent guardrails. |
Branch edits are isolated; and call_limits / privacy / auth are shared per-agent (cannot drift per branch) — guardrails can't silently diverge. |
Override fields sent as ""/null |
Sending an empty override clobbers the agent default instead of falling through. | Unchanged on a branch — but the rule still holds: omit fields you don't want to override; never send ""/null. |
| Env var missing requested environment | Silently falls back to production — you think you're hitting dev, you're hitting prod (real SMS/booking). |
Branch + environment=staging is explicit; still: always define a staging value (enforced by the Phase 0 staging-env gate), and every var must have a production value (the only hard failure). |
| Versioning is irreversible | Once enabled on an agent, versioning cannot be disabled. | Not a problem in practice — it's the prerequisite for this whole pattern. Cloning is the fallback only if an agent lacks versioning. |
Consequences
Positive
- Main is never touched until merge. Live Camellia traffic is structurally protected; isolation is enforced by the platform, not by engineer discipline.
- Config-only rollback. Re-deploy Main 100% (or serve a prior
version_id) — no rebuild, no redeploy. (Propagation latency for in-flight/next calls is asserted "fast" but not yet measured — see Phase 0; until measured, treat rollback as "fast, latency TBD," not "instant.") - No analytics fragmentation. Conversations carry
branch_id/version_id/environment, so canary metrics (CSAT, containment, conversion, AHT, latency, cost-per-resolution) are cleanly segmented on one agent. - Deterministic routing (
conversation_id-sticky) gives consistent UX per caller; A/B comparisons are valid only when restricted to first-timeconversation_ids (see below). - One agent + tool config across environments via env vars — promotion does not require config edits to repoint URLs/secrets.
- Cloning's failure modes are designed out (see Pitfalls): inherited tools, override toggle, and phone wiring all come for free.
Negative / costs
- Two test surfaces still required. Text tests are voice-blind; every promotion needs a separate audio/latency smoke pass (rung 3). This is a process cost, not optional.
- Deterministic stickiness biases the canary. A 5% split is 5% of conversation_ids, sticky per caller, so repeat/known callers always land the same side and the canary cohort can be structurally different from Main's. Mitigation (mandatory): the regression comparison is computed over fresh / first-time
conversation_ids only; conclusions drawn over repeat callers are invalid. Also: a single test caller can't randomly sample branches, and small samples skew — the §Canary exit criteria impose a sample floor. - No built-in threshold alarms. EL gives no statistical-significance threshold and no native alerting; we build the kill-switch alarm in Phase 0 (off post-call webhooks / polling) and the canary may not start until it fires correctly on a seeded bad call.
- Shared per-agent fields are a footgun in reverse: a
name/tagsedit on a non-Main branch silently does nothing until merge — easy to "lose" a rename. - A few API shapes are still flagged unverified (see below) — code defensively and confirm against the live OpenAPI before relying on them.
Flagged uncertainties (confirm against live OpenAPI / hands-on before relying on them)
- Deployment percentage unit — whole (
50) vs fraction (0.5). Hands-on confirms the split sums to 100%; the per-item unit is UNCONFIRMED and is pinned by a Phase 0 probe before any deployment write. (Elevated out of footnote — this is a live-traffic risk, not a footnote.) - Deployment array shape — verified as
[{branch_id, percentage}]summing to 100. Single-item arrays are unsafe (blackhole risk); always post both Main and branch entries. - Triage transfer + split/branch resolution — whether
transfer_to_agenthonors theagent_8601deployment split and the engineer-phonebranch_id. UNVERIFIED; Phase 0 probe gates Phase 3. - Merge endpoint shape — the route is path-style
POST /agents/{id}/branches/{source_branch_id}/merge(read-only probe 2026-06-04:GET …/branches/{source}/merge→ 405 = route exists, POST-only;GET …/branches/merge→ 404 = the body-style route does not exist). An earlier draft of this ADR labeled the body-stylePOST /branches/mergeas "verified hands-on" — that was wrong: the body-style route was never executed and does not exist. The merge body/behavior (whether the target must be Main, auto-redeploy) is still unverified — Phase 5 executes it on a non-prod agent first and re-asserts the deployment explicitly. Treat Main-only as the verified-safe target. - Post-merge rollback call — deploying a prior
version_idto Main 100% is the rollback target, but only the read of a version (GET ?version_id=) is verified; the deploy-a-prior-version-to-Main call is asserted, not verified. Phase 5 spells out the exact call and requires verifying it before merge. - Branch inheritance of
enable_conversation_initiation_client_data_from_webhook— the clone failure is verified (1008 crash); the branch inheriting the toggle is expected, not proven. Phase 0 reads the branch config and confirms the toggle is present. - Webhook
branch_id/environmentecho fields — work in the PropFlow codebase and appear in examples, but are not a formally documented public contract; verified positively and negatively in Phase 0. tool_latency_secsfield name andsimulate-conversation/test-invocations/{id}poll-response sub-field names — partially truncated docs; confirm on first use.
Rollback triggers (hard rules — not judgment calls)
The kill switch (re-deploy Main 100%) is mandatory — an engineer must throw it without waiting for sign-off — the moment any of these fire during Phase 2–4:
- Any false tour confirmation (agent confirmed a booking the tool did not make / errored) — zero tolerance, single occurrence.
- Any
save_prospect×N re-fire observed on a live branch call. - Containment drops > 5 points vs Main over the canary cohort (first-time
conversation_ids). - Conversion or CSAT drops > 10% relative vs Main over the cohort.
- p95 tool latency regresses > 25% vs Main, or any added tool round-trip pushes p95 turn latency past the live-acceptable band.
- The Phase 0 alarm fires on the live branch cohort.
After throwing the switch: keep the branch (do not archive), reproduce on the branch, add the failing case as a committed eval, fix, re-green the gate, and re-enter at Phase 1. The alarm wired in Phase 0 evaluates these triggers automatically off the post-call webhook; manual monitoring is a backstop, not the primary trigger.
Concurrency (one active canary at a time)
The deployment array must sum to 100 across all branches, so two simultaneous canaries on agent_8601 interfere. Rule: only one branch may be in Phase 3–4 at a time. Before posting any split, GET /agents/{id}/branches (read results[].current_live_percentage; GET /deployments is 405) and assert Main is at 100% (no other branch live). If a second change is ready, it waits, or it is folded into the active branch and re-greened. This is a soft lock by convention plus the mandatory pre-split read in the isolation checklist.
Roadmap — the staged promotion pipeline
Small phases. Each stage has entry criteria, exit criteria, a kill switch, and observability, plus a human "go" at every boundary (per-PR / per-stage approval — never auto-ramp). Wire one variable per change (isolate the experiment). Live-traffic mutation steps (Phases 2–5) are Fede-executed until Gera's access is provisioned (see Phase 0).
Phase 0 — One-time setup + verification gates (per agent)
Setup:
- Confirm
agent_8601has versioning on (it does) and a Main branch at 100% (GET /agents/{id}/branches(readresults[].current_live_percentage;GET /deploymentsis 405)). - Executor access. Confirm the executor has live-mutation access (EL API key with deploy/merge scope; PropFlow env-var write access where the staging branch env vars live). Gera's onboarding is incomplete (AWS IAM + security group pending per project memory) — until provisioned and Fede-confirmed, Gera runs read-only/non-mutating steps only; Fede runs Phases 2–5.
- Seed the eval suite (intent-capture clear/wrong-number, never-confirm-on-error,
no_prospectregression, Triage-transfer-reaches-Leasing) in the Tests API.
Hard verification gates (each must pass before the phase it unblocks):
- Percentage-unit gate (unblocks any deployment / Phase 3). Probe:
POST /agents/{id}/deployments [{branch_id: <main>, percentage: <max>}, {branch_id: <branch-at-0>, percentage: <0-equivalent>}], read it back, confirm whether the echoed value is whole (50) or fraction (0.5). Record the unit in this ADR. No live split runs until pinned. - Staging env-var gate (unblocks Phase 1 sims and Phase 2 calls). For every side-effecting tool (
save_prospect,schedule_tour,create_work_order, any SMS sender), assert an EL environment variable exists with a defined non-prodstagingvalue (not justproduction). Probe: resolve each tool URL underenvironment=stagingand confirm it points at a staging backend, not prod. A missingstagingvalue silently falls back to prod → a "staging" call writes real SMS/bookings. Hard fail if any side-effecting tool lacks a staging value. - Personalization-webhook routing gate (unblocks Phase 2). Positive: curl the personalization webhook with the engineer's dedicated-test caller_id and assert it returns the branch's
branch_id. Negative: curl with a non-listed number and assertbranch_idis null / Main. (Where: the env vars live in the PropFlow Vercel env that backs the personalization webhook; probe the deployed webhook URL.) - Override-toggle inheritance check (unblocks reliance on inheritance).
GET /agents/{id}?branch_id={b}&include_draft=trueand confirmenable_conversation_initiation_client_data_from_webhookis present on the branch (expected-not-proven until read). - Triage-transfer routing probe (unblocks Phase 3). Route the engineer's dedicated-test caller_id to the branch, place a call through Triage (
+18445101007), let ittransfer_to_agentinto Leasing, then pull the resultingagent_8601conversation and assert it carries the branch'sbranch_id. If transfers pin to Main, document the canary adjustment (split on Triage, or dial specialist directly) before Phase 3. - Build + verify the regression alarm (unblocks Phase 3). Implement the post-call alarm that consumes the post-call webhook (HMAC-verified, returns 200) and evaluates the §Rollback triggers per call, segmented by
branch_idover first-timeconversation_ids. Test it: seed a bad call (false confirmation / re-fire) and confirm the alarm fires and pages. The canary may not start until the alarm fires correctly on the seeded bad call. - Rollback propagation measurement (unblocks "fast rollback" claim). Post a split to the branch, then re-deploy Main 100%, and measure how long until the next call routes to Main. Record the number; if it lags, the §Rollback triggers and monitoring cadence account for it.
Phase 1 — Branch + eval-green (zero live traffic)
- Entry: an approved change in scope; a fresh branch off the current live Main version; percentage-unit and staging-env gates green.
- Do: edit via
?branch_id=; run rung-1 sim for the inner loop; runrun-testswithagent_config_override+tool_mock_config (MockingStrategy=all); poll to completion. - Exit: every eval
result === "success"(unknown= fail), including the Triage-transfer eval; Main confirmed unchanged viaGET /agents/{id}. - Kill switch: delete the branch / discard the draft (no live impact).
- Observability: test-invocation results; sim transcript +
analysis. - Human gate: PR review of the branch diff + green evals.
Phase 2 — Route the engineer's phone (live number, one caller)
- Entry: Phase 1 exit met; staging-env, webhook-routing, and override-toggle gates green.
- Do: add the engineer's dedicated-test caller_id (never a number that also receives prospect traffic) to
ELEVENLABS_STAGING_BRANCH_PHONES→ branch. Place a real call: dial+18445101007, go through Triage, andtransfer_to_agentinto Leasing (the real live path). Side effects run against staging (environment=staging, proven by the Phase 0 gate). Pull the EL conversation (GET /conversations/{id}): tool sequence, per-tool latency, results,termination_reason. This is the only rung that exercises personalization + voice turn-taking and surfaces voice-only re-fires. - Exit: correct tool sequence (no
save_prospect×N re-fire), no false confirmation on any tool error, latency acceptable,termination_reasonclean. - Kill switch: remove the caller_id from the env list → that phone falls back to Main instantly. Everyone else was always on Main.
- Observability: the pulled conversation; per-tool latency; transcript.
- Human gate: engineer + Fede sign-off on the real-call review.
Phase 2.5 — Controlled prod-booking dry run (isolate the highest-risk live path)
- Entry: Phase 2 exit met.
- Do: with the engineer's dedicated-test caller_id on the branch but
environment=production, place one call that exercisesschedule_tourend-to-end against a designated test unit / test slot on the real Outlook calendar. This validates the production booking integration (real calendar write + real confirmation) in isolation, on an engineer, never on a prospect, before any canary. - Exit: the booking lands on the calendar exactly once, the agent's confirmation matches the actual booking, no false confirmation; then delete the test calendar event.
- Kill switch: remove the caller_id; cancel the test event.
- Observability: the pulled conversation + the Outlook calendar event.
- Human gate: Fede sign-off (prod side effect).
Phase 3 — Canary 5% (real traffic, small)
- Entry: Phase 2.5 exit met; Triage-transfer routing probe resolved; alarm verified firing; percentage unit pinned;
GET /agents/{id}/branchesshows Maincurrent_live_percentage100% (GET /deploymentsis 405) (no other canary live). - Do:
POST /agents/{id}/deployments [{branch_id: <main>, percentage: 95}, {branch_id: <b>, percentage: 5}](whole-number unit assumed; adjust if Phase 0 pinned fractions). Enable EL Monitoring in the console to watch live calls and take over a call if needed. - Exit (all must hold over the sample floor):
- Sample floor: ≥ 30 fresh/first-time
conversation_idconversations on the branch AND ≥ 3 business-hours days AND ≥ 1 call that actually exercisedsave_prospect+schedule_tour. "Meaningful volume" alone does not pass. - No CSAT / containment / conversion regression vs Main (comparison restricted to first-time
conversation_ids), latency and cost-per-resolution within band — all segmented bybranch_id/version_id. - No §Rollback trigger fired.
- Sample floor: ≥ 30 fresh/first-time
- Kill switch: re-deploy Main 100% (
[{branch_id: <main>, percentage: 100}]). Mandatory the instant any §Rollback trigger fires. - Observability:
GET /conversations?branch_id=...(branch cohort) and Main-cohort health + expected traffic share (control — catches a mis-split where Main's share is wrong); post-call webhook → the Phase 0 alarm; analytics dashboard A/B over first-time callers. - Human gate: explicit "ramp" approval.
Phase 4 — Ramp 10 → 25 → 50 → 100%
- Entry: Phase 3 exit met.
- Do: step deployments (always full two-element array, Main + branch summing to 100): branch 10/25/50/100, Main 90/75/50/0 — re-checking metrics over the §Canary sample floor at each step. One variable still isolated.
- Exit: clean metrics at 100% over the sample floor; no §Rollback trigger fired at any step.
- Kill switch: re-deploy Main 100% at any step. Mandatory on any §Rollback trigger.
- Observability: same branch-segmented metrics + Main-cohort control + monitoring + alarm.
- Human gate: approval at each ramp step (10, 25, 50, 100).
Phase 5 — Merge / promote
- Entry: Phase 4 exit met (branch effectively serving 100% with clean metrics over the sample floor).
- Do:
POST /agents/{id}/branches/{source_branch_id}/merge {target_branch_id=main, archive_source_branch=false}(path-style — route verified read-only 2026-06-04; execute the body on a non-prod agent first) → new Main version, traffic auto-moves to Main. Keep the source branch un-archived until Main soaks clean (preserves the forensics/diff and rollback trail).name/tagstake effect here. Env vars auto-resolve to prod values — no config edits at promotion.- Re-assert the deployment (do not assume merge auto-redeployed):
GET /agents/{id}/branches(readresults[].current_live_percentage;GET /deploymentsis 405) and, if it does not show Main at 100% on the new version, explicitlyPOST /agents/{id}/deployments [{branch_id: <main>, percentage: 100}].
- Re-assert the deployment (do not assume merge auto-redeployed):
- Exit: Main is the new version;
GET /agents/{id}/branchesshows Maincurrent_live_percentage100% (GET /deploymentsis 405); alarm clean over a soak window. - Post-merge rollback (exact call — this is the kill switch here): identify the prior Main version from
GET /agents/{id}/versions(or theversion_idrecorded at Phase 4), then deploy it to Main:POST /agents/{id}/deployments [{branch_id: <main>, version_id: <prior>, percentage: 100}]. This deploy-a-prior-version call is asserted, not yet verified — verify it against the live API (dry probe on a non-prod agent or against the branch) before relying on it at merge time. If the API requires a different shape to serve a prior version, record it here. - Archive later: once Main has soaked clean, archive the source branch (
archive_source_branch=trueon a follow-up, or the branch-archive call — requires the branch at 0% traffic). - Observability: post-merge conversations on the new Main version; standard alarms; the soak-window alarm.
- Human gate: final promotion approval (Fede).
Alternatives considered
Clone the agent (the deprecated approach). Make a copy, edit the copy, point a test DID at it, hand-copy config back to promote. Rejected. It hit
both_tools_and_tool_ids_provided, lost system tools, required copying theenable_conversation_initiation_client_data_from_webhooktoggle (a bare clone 1008-crashed with "Missing required dynamic variables in tools"), and required repointing the test phone number. A branch inherits all of that and never touches Main. Kept only as a fallback for an agent with versioning disabled.Edit Main directly behind a feature flag / off-hours. Rejected. No isolation — a mistake is live immediately on a real resident's call; rollback means another live edit; and analytics can't cleanly attribute before/after. Branches give isolation, config-only rollback, and clean attribution.
Stand up a separate staging agent on a separate number. Rejected as the primary path. It re-introduces clone-style drift (two configs to keep in sync, tools/toggles to re-wire) and never tests the actual live number's personalization path. A dedicated test DID pinned to a branch via
PATCH /phone-numbers/{id}(branch_id+environment) — i.e. whole-line routing viaELEVENLABS_STAGING_BRANCH_NUMBERS— is available as a complement for whole-line test traffic, but it carries the same eval gate (no line may be pointed at a branch without Phase 1 green) and is otherwise unused in the default flow. The engineer-phone-on-the-live-number route is the higher-fidelity default; this alternative exists so the danglingELEVENLABS_STAGING_BRANCH_NUMBERScapability has a defined, gated role rather than inviting an ungated whole-line repoint.Promote on text-eval green alone (skip the real call). Rejected. Text tests are voice-blind: they miss voice re-fires (
save_prospect×3), ASR/TTS, turn-taking, barge-in, and personalization. The ladder requires a real call (rung 3) before any live traffic.Random per-call traffic splitting. Rejected — EL routing is deterministic per
conversation_idby design, which gives consistent per-caller UX. We accept the trade-off (a single test caller can't randomly sample branches; canary cohorts skew toward repeat callers) and mitigate by restricting A/B comparisons to first-timeconversation_ids and relying on real canary volume over the sample floor.