Skip to content

Diagnostic Twilio

answering machine detection is routing humans to voicemail

Connect rates are down and nobody can say why. The calls go out, they are answered, they last a few seconds, and they end. Every one of them is completed. What happened is that a person said hello, Twilio decided they were an answering machine, and your flow did what you told it to do for machines: it started a voicemail drop at somebody who was standing there holding the phone.

Read-only key Python and Node.js Tests included
A cable network
Photo by Taylor Vick on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Calls.json?StartTime>=YYYY-MM-DD&PageSize=1000 and tally answered_by across the completed calls. With MachineDetection=Enable the values are human, machine_start, fax and unknown; with DetectMessageEnd you also get the machine_end_* family.

Two numbers decide it. An unknown share above a few percent means detection is timing out rather than deciding. A machine_start share well above the voicemail rate you would expect, concentrated in calls of a few seconds, is the misroute: that short duration is a human hanging up on a voicemail greeting aimed at them.

The problem in plain words

Nothing here is an error, so nothing here is logged as one. Answering-machine detection is a judgement Twilio makes in the first seconds of audio and hands to you in a webhook parameter. Whatever your flow does with that judgement is your code running correctly on a wrong input. The call completes, it is billed, the Debugger is empty, and the only visible symptom is a business metric moving in the wrong direction.

Which means it is diagnosed as a list problem or a script problem, because those are the usual causes of a falling connect rate. It survives that investigation intact: the list is fine, the script is fine, and the detector in between is quietly reclassifying a slice of your live humans as machines every single day. The share is rarely large enough to be obvious and rarely small enough not to matter.

Call answereda person sayshelloDetectiondecidesa few seconds ofaudiomachine_startslow greeting,noisy lineVoicemailbranchdrop startsplayingCaller hangs upbilled, completed
Nothing here is an error. Your flow branched correctly on the value it was given, and the value was wrong.

Why it happens

Detection has a few seconds of audio and a hard deadline. It is listening for the shape of a greeting: how long the speech runs, whether it stops. A person who answers with a long "hello, this is Sam speaking, how can I help" produces the same shape as a recorded greeting, and a line with hold music or background noise produces something detection cannot parse at all.

unknown is a timeout, not a category. It does not mean Twilio decided the call was ambiguous. It means the deadline passed before a decision was reached, and your flow got a value it almost certainly has no branch for. Flows tend to treat unknown as machine, because the machine branch is the safe-looking one.

The default mode answers early on purpose. MachineDetection=Enable is optimised to return as soon as it can, which is what you want for a dialler and is exactly what produces borderline calls. DetectMessageEnd waits for the greeting to finish, which is slower and far more certain, and it is a different value in a different field on the create request.

The evidence is an aggregate, so no single call proves anything. Pull up one machine_start call and it looks entirely reasonable. Only the distribution across a few hundred calls, cut by duration, shows you that a quarter of your "machines" hung up after four seconds.

The fix, as a flow

The script counts only the calls detection actually graded. Leaving the unanswered and undetected ones in the denominator is what produces a reassuring machine share on a campaign that is failing.

answered_by talliedmachine_start split by durationHuman majoritydetection is workingToo few graded callswiden the windowunknown over thresholddetection timing outMachines, short callshumans in the drop
unknown and machine_start are different faults with different levers, so a report that adds them together names neither.

How to fix it

Page the calls over a window you actually campaigned in

GET /2010-04-01/Accounts/{AccountSid}/Calls.json?StartTime>=YYYY-MM-DD&PageSize=1000, following next_page_uri, which on this API is a path rather than an absolute URL. Pick a window with real volume in it; a distribution over forty calls is noise wearing a percentage sign.

Count only the calls detection actually graded

A call with no answered_by never asked for detection, and a call whose status is not completed was never answered by anything. Both belong out of the denominator. Leaving them in is what produces the reassuring report: hundreds of calls, a tiny machine share, and a campaign that is still failing.

Split machine_start by duration

This is the measurement that names the problem. A machine_start call lasting a few seconds is a human who heard a recording begin and hung up. Do not apply the same rule to machine_end_beep and its siblings: those come from DetectMessageEnd, where Twilio waited for the greeting to finish, so a short call there means something else.

Read unknown as a separate failure from machine_start

They have different repairs. A high unknown share is a timing problem: raise MachineDetectionTimeout and MachineDetectionSpeechThreshold. A high machine_start share with short durations is a mode problem: DetectMessageEnd, or AsyncAmd=true so the call connects to a human first and reclassifies afterwards through AsyncAmdStatusCallback.

Change one parameter, re-run over a fresh window, compare

Detection tuning is empirical and the only instrument is this distribution. Change MachineDetection or one threshold on the outbound create request, run the campaign, and tally the same window again. Keep the earlier numbers: a share that moved from 34% to 31% is not a fix, and without the previous run you will believe it was.

How to check it worked

Re-run over a window that starts after the change. The unknown share should be under a couple of percent and the short share of machine_start calls should collapse.

python3 twilio_amd_classification_audit.py --days 3
# healthy  620 graded call(s): human 71.0%, machine 26.0%, unknown 1.3%

The full code

One paginated GET over the calls and nothing else, with an API Key that has read access. Two pure functions carry the analysis: one puts a single call in a bucket, and one turns the tally of buckets into a verdict against thresholds you pass in. Separating them is what makes the thresholds arguable — they are defaults, not truths, and the only way to have that argument honestly is to be able to change the numbers and re-run without touching the bucketing.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_amd_classification_audit.py
"""Report how Twilio's answering machine detection is classifying your calls.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can place calls and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_amd_classification_audit")

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"

# DetectMessageEnd waits for the greeting to finish before deciding, so its
# verdicts arrive as this family rather than as machine_start.
MACHINE_END = ("machine_end_beep", "machine_end_silence", "machine_end_other")

GRADED = ("human", "machine", "machine-short", "unknown", "fax")


def seconds(value):
    """A call duration as an int. It arrives as a string and can be absent."""
    try:
        return int(str(value or "0").strip() or 0)
    except ValueError:
        return 0


def bucket(call, short_seconds=8):
    """Put one call in an answering-machine bucket. Pure, so the rules can be
    tested without a network.

    A machine_start call of a few seconds is the misroute this note is about: a
    person answered, detection called them a machine, and they hung up on the
    voicemail drop. The machine_end_* family is deliberately not split the same
    way, because there Twilio waited for the greeting to end and a short call
    means something else entirely.
    """
    if str(call.get("status") or "").strip().lower() != "completed":
        return "not-completed"

    answered = str(call.get("answered_by") or "").strip().lower()
    if not answered:
        return "no-amd"
    if answered in ("human", "fax", "unknown"):
        return answered
    if answered == "machine_start":
        return "machine-short" if seconds(call.get("duration")) <= short_seconds else "machine"
    if answered in MACHINE_END:
        return "machine"
    return "other"


def verdict(tally, min_calls=50, unknown_pct=3.0, machine_pct=40.0, short_pct=25.0):
    """Turn a tally of buckets into a verdict. Pure.

    The thresholds are arguments rather than constants because they are
    defaults, not truths: a debt collector's real voicemail rate is nothing like
    a delivery notification's. Returns (state, detail).
    """
    graded = sum(tally.get(k, 0) for k in GRADED)
    if graded == 0:
        return ("no-amd",
                "no call in this window carries answered_by, so machine "
                "detection was never requested and there is nothing to tune.")
    if graded < min_calls:
        return ("thin-sample",
                "only %d graded call(s), under the %d needed to read a "
                "distribution. Widen the window rather than trusting this."
                % (graded, min_calls))

    machines = tally.get("machine", 0) + tally.get("machine-short", 0)
    unknown_share = 100.0 * tally.get("unknown", 0) / graded
    machine_share = 100.0 * machines / graded
    short_share = (100.0 * tally.get("machine-short", 0) / machines) if machines else 0.0

    if unknown_share > unknown_pct:
        return ("detection-timing-out",
                "%.1f%% of %d graded call(s) came back unknown, over the %.1f%% "
                "threshold. unknown is a timeout, not a category: detection ran "
                "out of time and your flow branched on a value it has no case "
                "for." % (unknown_share, graded, unknown_pct))

    if machine_share > machine_pct and short_share > short_pct:
        return ("over-classifying",
                "%.1f%% of %d graded call(s) were called machines and %.1f%% of "
                "those lasted seconds. That short tail is people hanging up on a "
                "voicemail drop aimed at them."
                % (machine_share, graded, short_share))

    if machine_share > machine_pct:
        return ("machine-heavy",
                "%.1f%% of %d graded call(s) were machines, over the %.1f%% "
                "threshold, but only %.1f%% of them were short. This looks like "
                "a list that really does reach voicemail, not a detector fault."
                % (machine_share, graded, machine_pct, short_share))

    return ("healthy",
            "%d graded call(s): human %.1f%%, machine %.1f%%, unknown %.1f%%"
            % (graded, 100.0 * tally.get("human", 0) / graded,
               machine_share, unknown_share))


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
                         "API key belongs to that account with read access"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def list_calls(session, account, since, limit):
    """Page the Calls listing. next_page_uri here is a path, not a URL."""
    url = "%s/Accounts/%s/Calls.json" % (BASE, account)
    params = {"StartTime>=": since, "PageSize": 1000}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("calls", []))
        nxt = page.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=7, help="window to tally")
    ap.add_argument("--max-calls", type=int, default=20000,
                    help="stop after this many calls")
    ap.add_argument("--short-seconds", type=int, default=8,
                    help="a machine_start call this short is a suspected misroute")
    ap.add_argument("--min-calls", type=int, default=50,
                    help="fewer graded calls than this is not a distribution")
    ap.add_argument("--unknown-pct", type=float, default=3.0,
                    help="unknown share above this is a detection timeout")
    ap.add_argument("--machine-pct", type=float, default=40.0,
                    help="machine share above this is worth explaining")
    args = ap.parse_args()

    account = os.environ.get("TWILIO_ACCOUNT_SID")
    key = os.environ.get("TWILIO_API_KEY")
    secret = os.environ.get("TWILIO_API_SECRET")
    if not (account and key and secret):
        log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
                  "(an API Key with read access, not the auth token)")
        return 2

    session = requests.Session()
    session.auth = (key, secret)

    since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    calls = list_calls(session, account, since, args.max_calls)
    if not calls:
        log.info("no calls in the last %d day(s)", args.days)
        return 0

    tally = {}
    for c in calls:
        b = bucket(c, args.short_seconds)
        tally[b] = tally.get(b, 0) + 1

    for name in sorted(tally):
        log.info("%-14s %d", name, tally[name])

    state, detail = verdict(tally, args.min_calls, args.unknown_pct,
                            args.machine_pct)
    if state in ("healthy", "no-amd", "thin-sample"):
        log.info("%s  %s", state, detail)
        return 0

    log.warning("%s  %s", state, detail)
    log.warning("  repair: on the outbound create request set "
                "MachineDetection=DetectMessageEnd, or raise "
                "MachineDetectionTimeout and MachineDetectionSpeechThreshold")
    log.warning("  repair: or set AsyncAmd=true with AsyncAmdStatusCallback so "
                "the call connects first and is reclassified after")
    return 1


if __name__ == "__main__":
    sys.exit(main())
twilio-amd-classification-audit.mjs
/**
 * Report how Twilio's answering machine detection is classifying your calls.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed.
 */
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;

// DetectMessageEnd waits for the greeting to finish, so its verdicts arrive as
// this family rather than as machine_start.
const MACHINE_END = ['machine_end_beep', 'machine_end_silence', 'machine_end_other'];

const GRADED = ['human', 'machine', 'machine-short', 'unknown', 'fax'];

/** A call duration as a number. It arrives as a string and can be absent. */
export function seconds(value) {
  const n = Number.parseInt(String(value ?? '0').trim(), 10);
  return Number.isFinite(n) ? n : 0;
}

/**
 * Put one call in an answering-machine bucket. Pure.
 *
 * A machine_start call of a few seconds is the misroute: a person answered,
 * detection called them a machine, and they hung up on the voicemail drop. The
 * machine_end_* family is not split the same way, because there Twilio waited
 * for the greeting to end and a short call means something else.
 */
export function bucket(call, shortSeconds = 8) {
  if (String(call.status ?? '').trim().toLowerCase() !== 'completed') return 'not-completed';

  const answered = String(call.answered_by ?? '').trim().toLowerCase();
  if (!answered) return 'no-amd';
  if (['human', 'fax', 'unknown'].includes(answered)) return answered;
  if (answered === 'machine_start') {
    return seconds(call.duration) <= shortSeconds ? 'machine-short' : 'machine';
  }
  if (MACHINE_END.includes(answered)) return 'machine';
  return 'other';
}

/**
 * Turn a tally of buckets into a verdict. Pure. The thresholds are arguments
 * rather than constants because they are defaults, not truths. Returns
 * [state, detail].
 */
export function verdict(tally, minCalls = 50, unknownPct = 3.0, machinePct = 40.0,
                        shortPct = 25.0) {
  const graded = GRADED.reduce((n, k) => n + (tally[k] ?? 0), 0);
  if (graded === 0) {
    return ['no-amd',
      'no call in this window carries answered_by, so machine detection was ' +
      'never requested and there is nothing to tune.'];
  }
  if (graded < minCalls) {
    return ['thin-sample',
      `only ${graded} graded call(s), under the ${minCalls} needed to read a ` +
      'distribution. Widen the window rather than trusting this.'];
  }

  const machines = (tally.machine ?? 0) + (tally['machine-short'] ?? 0);
  const unknownShare = (100 * (tally.unknown ?? 0)) / graded;
  const machineShare = (100 * machines) / graded;
  const shortShare = machines ? (100 * (tally['machine-short'] ?? 0)) / machines : 0;

  if (unknownShare > unknownPct) {
    return ['detection-timing-out',
      `${unknownShare.toFixed(1)}% of ${graded} graded call(s) came back ` +
      `unknown, over the ${unknownPct.toFixed(1)}% threshold. unknown is a ` +
      'timeout, not a category: detection ran out of time and your flow ' +
      'branched on a value it has no case for.'];
  }

  if (machineShare > machinePct && shortShare > shortPct) {
    return ['over-classifying',
      `${machineShare.toFixed(1)}% of ${graded} graded call(s) were called ` +
      `machines and ${shortShare.toFixed(1)}% of those lasted seconds. That ` +
      'short tail is people hanging up on a voicemail drop aimed at them.'];
  }

  if (machineShare > machinePct) {
    return ['machine-heavy',
      `${machineShare.toFixed(1)}% of ${graded} graded call(s) were machines, ` +
      `over the ${machinePct.toFixed(1)}% threshold, but only ` +
      `${shortShare.toFixed(1)}% of them were short. This looks like a list ` +
      'that really does reach voicemail, not a detector fault.'];
  }

  return ['healthy',
    `${graded} graded call(s): human ` +
    `${((100 * (tally.human ?? 0)) / graded).toFixed(1)}%, machine ` +
    `${machineShare.toFixed(1)}%, unknown ${unknownShare.toFixed(1)}%`];
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

async function get(auth, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const res = await fetch(u, { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
                    'that the API key belongs to that account with read access');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

export async function listCalls(auth, account, since, limit = 20000) {
  let url = `${BASE}/Accounts/${account}/Calls.json`;
  let params = { 'StartTime>=': since, PageSize: 1000 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.calls ?? []));
    url = page.next_page_uri ? HOST + page.next_page_uri : null;
    params = {};
  }
  return out.slice(0, limit);
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const arg = (name, fallback) => {
    const i = process.argv.indexOf(name);
    return i === -1 ? fallback : Number(process.argv[i + 1]);
  };
  const days = arg('--days', 7);
  const shortSeconds = arg('--short-seconds', 8);

  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  const calls = await listCalls(auth, account, since);
  if (calls.length === 0) {
    console.log(`no calls in the last ${days} day(s)`);
    return;
  }

  const tally = {};
  for (const c of calls) {
    const b = bucket(c, shortSeconds);
    tally[b] = (tally[b] ?? 0) + 1;
  }
  for (const name of Object.keys(tally).sort()) {
    console.log(`${name.padEnd(14)} ${tally[name]}`);
  }

  const [state, detail] = verdict(tally, arg('--min-calls', 50),
                                  arg('--unknown-pct', 3.0), arg('--machine-pct', 40.0));
  if (['healthy', 'no-amd', 'thin-sample'].includes(state)) {
    console.log(`${state}  ${detail}`);
    return;
  }
  console.warn(`${state}  ${detail}`);
  console.warn('  repair: on the outbound create request set ' +
               'MachineDetection=DetectMessageEnd, or raise ' +
               'MachineDetectionTimeout and MachineDetectionSpeechThreshold');
  console.warn('  repair: or set AsyncAmd=true with AsyncAmdStatusCallback so ' +
               'the call connects first and is reclassified after');
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module from the test file
// does not fire main(), fail on the missing credentials and set an exit code
// that fails the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The bucketing tests are the ones that matter, because the denominator is where this analysis is usually lost. A call with no answered_by and a call that was never completed both have to stay out of the graded set, or a campaign with a thousand unanswered dials reports a comfortable two percent machine rate. The other pinned case is the asymmetry between machine_start and machine_end_beep at the same short duration: only the first is evidence.

test_twilio_amd_classification_audit.py
from twilio_amd_classification_audit import bucket, verdict


def test_short_machine_start_is_the_misroute_bucket():
    assert bucket({"status": "completed", "answered_by": "machine_start",
                   "duration": "4"}) == "machine-short"


def test_machine_end_beep_is_not_split_by_duration():
    # DetectMessageEnd waited for the greeting, so a short call means something
    # else and must not land in the misroute bucket.
    assert bucket({"status": "completed", "answered_by": "machine_end_beep",
                   "duration": "4"}) == "machine"


def test_calls_without_detection_stay_out_of_the_denominator():
    assert bucket({"status": "completed", "duration": "90"}) == "no-amd"
    assert bucket({"status": "no-answer", "answered_by": "unknown"}) == "not-completed"


def test_unknown_share_over_the_threshold_reads_as_a_timeout():
    state, detail = verdict({"human": 400, "machine": 80, "unknown": 30})
    assert state == "detection-timing-out"
    assert "timeout, not a category" in detail


def test_machine_heavy_with_a_short_tail_is_over_classifying():
    state, detail = verdict({"human": 100, "machine": 60, "machine-short": 40})
    assert state == "over-classifying"
    assert "hanging up" in detail


def test_machine_heavy_without_a_short_tail_is_a_list_not_a_detector():
    state, _ = verdict({"human": 100, "machine": 98, "machine-short": 2})
    assert state == "machine-heavy"


def test_thin_sample_is_reported_rather_than_scored():
    assert verdict({"human": 10, "machine": 4})[0] == "thin-sample"


def test_no_graded_calls_means_detection_was_never_asked_for():
    assert verdict({"no-amd": 900, "not-completed": 100})[0] == "no-amd"
twilio-amd-classification-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bucket, verdict } from './twilio-amd-classification-audit.mjs';

test('short machine_start is the misroute bucket', () => {
  assert.equal(
    bucket({ status: 'completed', answered_by: 'machine_start', duration: '4' }),
    'machine-short');
});

test('machine_end_beep is not split by duration', () => {
  assert.equal(
    bucket({ status: 'completed', answered_by: 'machine_end_beep', duration: '4' }),
    'machine');
});

test('calls without detection stay out of the denominator', () => {
  assert.equal(bucket({ status: 'completed', duration: '90' }), 'no-amd');
  assert.equal(bucket({ status: 'no-answer', answered_by: 'unknown' }), 'not-completed');
});

test('unknown share over the threshold reads as a timeout', () => {
  const [state, detail] = verdict({ human: 400, machine: 80, unknown: 30 });
  assert.equal(state, 'detection-timing-out');
  assert.match(detail, /timeout, not a category/);
});

test('machine heavy with a short tail is over classifying', () => {
  const [state, detail] = verdict({ human: 100, machine: 60, 'machine-short': 40 });
  assert.equal(state, 'over-classifying');
  assert.match(detail, /hanging up/);
});

test('machine heavy without a short tail is a list not a detector', () => {
  assert.equal(verdict({ human: 100, machine: 98, 'machine-short': 2 })[0],
               'machine-heavy');
});

test('thin sample is reported rather than scored', () => {
  assert.equal(verdict({ human: 10, machine: 4 })[0], 'thin-sample');
});

test('no graded calls means detection was never asked for', () => {
  assert.equal(verdict({ 'no-amd': 900, 'not-completed': 100 })[0], 'no-amd');
});

FAQ

What does answered_by unknown actually mean?

That detection did not reach a decision before its deadline. It is a timeout rather than a third category, which matters because flows written against human and machine tend to fall through to the machine branch on unknown. Raising MachineDetectionTimeout and MachineDetectionSpeechThreshold is the lever for it.

Why treat machine_start differently from machine_end_beep?

They come from different modes. machine_start is Enable deciding as early as it can, which is where borderline humans get miscalled. The machine_end_* family comes from DetectMessageEnd, which waits for the greeting to finish, so a short call there is not the same signal and folding them together destroys the measurement.

Is there an error code or a Debugger alert for this?

No. Detection returning the wrong answer is not a failure of anything: the call completed, it was billed, and your flow branched correctly on the value it was given. This is only visible as a distribution, which is why the script counts rather than filters.

Why does the denominator exclude calls with no answered_by?

Because those calls never requested detection, so they say nothing about how it is performing. Leaving them in dilutes every share toward zero and produces a report that looks healthy on a campaign that is failing. The same goes for calls that were never completed.

Should I use AsyncAmd instead of tuning the thresholds?

It is a different trade rather than a better one. AsyncAmd=true connects the call immediately and delivers the classification afterwards through AsyncAmdStatusCallback, so a human is never held waiting for a decision. You pay for it with a flow that has to handle being told, mid-call, that it is talking to a machine.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.