> ## Documentation Index
> Fetch the complete documentation index at: https://www.vobiz.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Conference XML Visualizer

> Step through a moderated Vobiz conference - who waits, who starts the room, who ends it - and see every enter and exit callback, waitSound fetch, and XML. Then fill a sandbox room with PSTN, outbound, SIP, and WebRTC legs and drive them with the mute, deaf, play, kick, and hangup member APIs.

export const ConferenceVisualizer = () => {
  const [mode, setMode] = React.useState("guided");
  const [scene, setScene] = React.useState(cvEmptyScene);
  const onScene = React.useCallback(s => setScene(s), []);
  return <div className="conference-visualizer not-prose">
      <main className="cv-shell">
        <div className="cv-intro">
          <p className="cv-eyebrow">Interactive conference room</p>
          <h1>One room, many legs. See who waits, who starts it, who ends it.</h1>
          <p className="cv-lede">
            A conference is a named room, not a call. Every leg that executes <code>&lt;Conference&gt;</code> is bridged into the same audio and
            carries its own attributes. <b>Guided standup</b> walks a moderated room step by step, with the webhook and XML for each step.
            <b> Room sandbox</b> lets you add PSTN, outbound, SIP and WebRTC legs and drive them with the member APIs.
          </p>
        </div>

        <div className="cv-board">
          <div className="cv-modes" role="tablist">
            <button type="button" role="tab" aria-selected={mode === "guided"} className={mode === "guided" ? "is-on" : ""} onClick={() => setMode("guided")}>① Guided standup</button>
            <button type="button" role="tab" aria-selected={mode === "sandbox"} className={mode === "sandbox" ? "is-on" : ""} onClick={() => setMode("sandbox")}>② Room sandbox</button>
            <span className="cv-modehint">{mode === "guided" ? "step through a moderated room" : "add legs, click one, run the member APIs"}</span>
          </div>
          <CvScene {...scene} />
          {mode === "guided" ? <CvGuided onScene={onScene} /> : <CvSandbox onScene={onScene} />}
          <div className="cv-legend">
            <span><i style={{
    borderColor: "var(--cv-amber)"
  }} />waiting on hold</span>
            <span><i style={{
    borderColor: "var(--cv-mint)"
  }} />bridged audio</span>
            <span><i style={{
    borderColor: "var(--cv-mint)",
    borderTopStyle: "dashed"
  }} />muted</span>
            <span><i style={{
    borderColor: "var(--cv-amber)",
    borderTopStyle: "dashed"
  }} />deaf</span>
            <span><i style={{
    borderColor: "var(--cv-ice)"
  }} />REST control · private audio</span>
            <span><i style={{
    borderColor: "var(--cv-sig)"
  }} />webhook to your server</span>
            <span><i style={{
    borderColor: "var(--cv-rose)"
  }} />removed</span>
          </div>
        </div>

        <div className="cv-footer-link">
          <a href="/docs/xml/conference">Conference element <span aria-hidden="true">→</span></a>
          <a href="/docs/xml/conference/attributes">Attributes <span aria-hidden="true">→</span></a>
          <a href="/docs/xml/conference/conference-callbacks">Callbacks <span aria-hidden="true">→</span></a>
          <a href="/docs/conference/members">Member APIs <span aria-hidden="true">→</span></a>
        </div>
      </main>
    </div>;
};

export const cvEmptyScene = () => ({
  members: [],
  roomN: 0,
  roomSt: "idle",
  roomName: CV_ROOM,
  roomLabel: "no room",
  server: {
    cable: "hook",
    state: "ring",
    sub: "callbackUrl · REST API",
    text: "receiving webhooks"
  },
  empty: ""
});

export const CvSandbox = ({onScene}) => {
  const [type, setType] = React.useState("pstn");
  const [members, setMembers] = React.useState([]);
  const [sel, setSel] = React.useState(null);
  const [scope, setScope] = React.useState("one");
  const [log, setLog] = React.useState([]);
  const [term, setTerm] = React.useState(null);
  const nextRef = React.useRef(10);
  const pushLog = entries => setLog(l => entries.concat(l).slice(0, 12));
  const live = members.filter(m => !m.leaving);
  const selected = live.find(m => m.id === sel) || null;
  const n = live.length;
  const addMember = () => {
    if (members.length >= SB_MAX) return;
    const mid = String(nextRef.current++);
    const m = {
      id: sbUuid(Number(mid)),
      mid,
      type,
      face: SB_TYPES[type].face,
      label: SB_TYPES[type].label,
      num: sbNum(type, Number(mid) + 200),
      muted: false,
      deaf: false,
      playing: false,
      leaving: false
    };
    const first = members.length === 0;
    setMembers(ms => ms.concat(m));
    setSel(m.id);
    pushLog([{
      kind: "enter",
      text: `enter · member ${mid} · ConferenceFirstMember=${first}`
    }]);
    setTerm({
      dir: "in",
      label: "callbackUrl · enter",
      route: "POST /conference-events",
      payload: {
        ConferenceAction: "enter",
        Event: "ConferenceEnter",
        ConferenceName: SB_ROOM,
        ConferenceMemberID: mid,
        ConferenceFirstMember: String(first),
        CallUUID: m.id,
        From: m.num,
        Direction: type === "out" ? "outbound" : "inbound",
        CallStatus: "in-progress"
      },
      outLabel: "your response",
      outKind: "text",
      out: "HTTP 204 · callbacks only notify you."
    });
  };
  const targets = () => scope === "all" ? live : live.filter(m => m.id === sel);
  const seg = () => scope === "all" ? "all" : selected ? selected.mid : "";
  const patch = (ids, fn) => setMembers(ms => ms.map(m => ids.includes(m.id) ? {
    ...m,
    ...fn(m)
  } : m));
  const remove = (ts, how) => {
    const ids = ts.map(m => m.id);
    patch(ids, () => ({
      leaving: true
    }));
    pushLog([{
      kind: "api",
      text: how === "kick" ? "kicked legs continue with the XML after <Conference>" : "hung-up legs end; hangup webhook follows"
    }]);
    window.setTimeout(() => {
      setMembers(ms => {
        const remaining = ms.filter(m => !ids.includes(m.id));
        const entries = [];
        ts.forEach((m, i) => {
          entries.unshift({
            kind: "exit",
            text: `exit · member ${m.mid} · ConferenceLastMember=${remaining.length === 0 && i === ts.length - 1}`
          });
          if (how === "hang") entries.unshift({
            kind: "hangup",
            text: `hangup · CallUUID ${m.id.slice(0, 8)}… · NORMAL_CLEARING`
          });
        });
        pushLog(entries);
        setSel(s => remaining.find(m => m.id === s) ? s : remaining.length ? remaining[remaining.length - 1].id : null);
        return remaining;
      });
    }, 900);
  };
  const act = kind => {
    const ts = targets();
    if (!ts.length) return;
    const ids = ts.map(m => m.id);
    const mids = ts.map(m => m.mid);
    const path = suffix => `${SB_BASE}/Member/${seg()}/${suffix}`;
    const out = (label, route, payload, outLabel, res) => setTerm({
      dir: "out",
      label,
      route,
      payload,
      outLabel,
      outKind: typeof res === "string" ? "text" : "json",
      out: res
    });
    switch (kind) {
      case "mute":
        patch(ids, () => ({
          muted: true
        }));
        out("mute member", "POST " + path("Mute/"), {}, "202 Accepted", {
          message: "muted",
          member_id: mids,
          api_id: "API_REQUEST_ID"
        });
        break;
      case "unmute":
        patch(ids, () => ({
          muted: false
        }));
        out("unmute member", "DELETE " + path("Mute/"), {}, "204 No Content", "HTTP 204 · empty body. The member can speak again.");
        break;
      case "deaf":
        patch(ids, () => ({
          deaf: true
        }));
        out("deaf member", "POST " + path("Deaf/"), {}, "202 Accepted", {
          message: "deaf",
          member_id: mids,
          api_id: "API_REQUEST_ID"
        });
        break;
      case "undeaf":
        patch(ids, () => ({
          deaf: false
        }));
        out("undeaf member", "DELETE " + path("Deaf/"), {}, "204 No Content", "HTTP 204 · empty body. Conference audio is restored for the member.");
        break;
      case "play":
        patch(ids, () => ({
          playing: true
        }));
        out("play audio to member", "POST " + path("Play/"), {
          url: "https://your-server.example/audio/notice.mp3"
        }, "202 Accepted", {
          message: "play queued into conference",
          api_id: "API_REQUEST_ID",
          member_id: mids
        });
        break;
      case "stop":
        patch(ids, () => ({
          playing: false
        }));
        out("stop audio", "DELETE " + path("Play/"), {}, "204 No Content", "HTTP 204 · empty body. Playback stops for the member.");
        break;
      case "kick":
        out("kick member", "POST " + path("Kick/"), {}, "202 Accepted", {
          message: "kicked",
          member_id: mids,
          api_id: "API_REQUEST_ID"
        });
        remove(ts, "kick");
        break;
      case "hang":
        out("hang up member", "DELETE " + path(""), {}, "204 No Content", "HTTP 204 · empty body. An exit callback follows, then the leg's hangup webhook.");
        remove(ts, "hang");
        break;
      default:
        break;
    }
  };
  const endRoom = () => {
    if (!live.length) return;
    setTerm({
      dir: "out",
      label: "hang up conference",
      route: `DELETE ${SB_BASE}/`,
      payload: {},
      outLabel: "204 No Content",
      outKind: "text",
      out: "HTTP 204 · empty body. Every member is disconnected and the room is gone; GET on the room name now returns 404."
    });
    remove(live, "hang");
  };
  React.useEffect(() => {
    onScene({
      members: members.map(m => {
        const state = m.leaving ? "kicked" : m.muted ? "muted" : m.deaf ? "hold" : "talk";
        const cable = m.leaving ? "kick" : m.playing ? "play" : m.deaf ? "deaf" : m.muted ? "muted" : "voice";
        const stText = m.leaving ? "being removed" : [m.muted && "muted", m.deaf && "deaf", m.playing && "playing"].filter(Boolean).join(" · ") || "in the room";
        return {
          key: m.id,
          role: m.label,
          face: m.face,
          num: m.num,
          mid: m.mid,
          state,
          stText,
          cable,
          deaf: m.deaf,
          playing: m.playing,
          leaving: m.leaving,
          selected: m.id === sel,
          onClick: () => setSel(m.id)
        };
      }),
      roomN: n,
      roomSt: n ? "live" : "end",
      roomName: SB_ROOM,
      roomLabel: n ? "conference live" : "no room",
      server: {
        cable: term && term.dir === "in" ? "hook" : "api",
        state: term && term.dir === "in" ? "ring" : "live",
        sub: "member APIs · X-Auth-ID / X-Auth-Token",
        text: term && term.dir === "in" ? "receiving webhooks" : "sending requests"
      },
      empty: n ? "" : "add a leg to create the room"
    });
  }, [members, sel, term]);
  const btn = (k, label, code, danger) => <button type="button" key={k} disabled={!selected} className={danger ? "danger" : ""} onClick={() => act(k)}><span>{label}</span><small>{code}</small></button>;
  const all = scope === "all";
  return <div>
      <div className="cv-sbbar">
        <label htmlFor="cv-sb-type">add a leg</label>
        <select id="cv-sb-type" value={type} onChange={e => setType(e.target.value)}>
          <option value="pstn">PSTN caller · inbound</option>
          <option value="out">Outbound call · Make Call API</option>
          <option value="sip">SIP endpoint · registered</option>
          <option value="rtc">WebRTC client · browser</option>
        </select>
        <button type="button" className="cv-add" disabled={members.length >= SB_MAX} onClick={addMember}>+ Join the room</button>
        <span className="cv-capnote">{n} / {CV_CAP} members · sandbox draws up to {SB_MAX}</span>
        <button type="button" className="cv-end" disabled={!n} onClick={endRoom}>⌁ Hang up conference</button>
      </div>
      <div className="cv-deck is-sb">
        <div className="cv-pane">
          <div className="cv-ctl">
            <h4>{selected ? all ? "All members" : `${selected.label} · member ${selected.mid}` : "No member selected"}</h4>
            <div className="cv-who">{selected ? all ? <span><b>{n}</b> members via <b>member_id=all</b></span> : <span><b>{selected.num}</b> · {SB_TYPES[selected.type].how}</span> : "Add a leg above, then click a member card."}</div>
            <div className="cv-grid">
              {selected && selected.muted && !all ? btn("unmute", "Unmute", "DELETE · 204") : btn("mute", "Mute", "POST · 202")}
              {selected && selected.deaf && !all ? btn("undeaf", "Undeaf", "DELETE · 204") : btn("deaf", "Deaf", "POST · 202")}
              {selected && selected.playing && !all ? btn("stop", "Stop audio", "DELETE · 204") : btn("play", "Play audio", "POST · 202")}
              {btn("kick", "Kick", "POST · 202", true)}
              {btn("hang", "Hang up member", "DELETE · 204", true)}
              {all ? btn("unmute", "Unmute all", "DELETE · 204") : null}
            </div>
            <div className="cv-scope">
              <label><input type="radio" name="cv-scope" value="one" checked={scope === "one"} onChange={() => setScope("one")} /> selected member</label>
              <label><input type="radio" name="cv-scope" value="all" checked={scope === "all"} onChange={() => setScope("all")} /> all members (<code>member_id=all</code>)</label>
            </div>
            <div className="cv-tip">Muted members still hear the room. Deaf members still speak. Both together isolate a member. Kick removes the leg and lets its XML continue; Hang up ends the leg outright.</div>
          </div>
          <div className="cv-events">
            <div className="cv-logtitle">Callbacks your server would receive</div>
            <div className="cv-log">
              {log.length ? log.map((e, i) => <div key={i} className="cv-row is-ev"><span className={"cv-ev " + e.kind}>{e.kind}</span><span>{e.text}</span></div>) : <div className="cv-row is-ev"><span className="cv-ev">—</span><span>enter and exit callbacks will appear here</span></div>}
            </div>
          </div>
        </div>
        <div className="cv-pane">
          {term ? <CvTerm {...term} /> : <div>
              <div className="cv-term">
                <div className="cv-head"><span className="cv-dir api">your server → Vobiz</span><span>· nothing sent yet</span></div>
                <pre><code className="cv-plain">Add a leg, select a member, and press an action.</code></pre>
              </div>
              <div className="cv-term"><div className="cv-head"><span>Vobiz responds</span></div><pre><code className="cv-plain">—</code></pre></div>
            </div>}
          <div className="cv-note">
            <b>Codes and bodies follow the member API reference.</b> Mute, Deaf, Kick and Play answer <code>202</code> with the affected member IDs;
            their DELETE counterparts and Hang Up answer <code>204</code> with no body. <code>member_id</code> can be one ID, a comma-separated list, or <code>all</code>.
            Member IDs can be reused after a member leaves, so correlate on <code>CallUUID</code> too.
          </div>
        </div>
      </div>
    </div>;
};

export const sbUuid = n => `${String(n).padStart(8, "0")}-c0de-4f00-8a1e-5a6d9b0${n % 10}0000`;

export const sbNum = (t, n) => t === "sip" ? `sip:agent${n}@vobiz.sip` : t === "rtc" ? `webrtc:browser-${n}` : `+14155550${String(n).padStart(3, "0")}`;

export const SB_TYPES = {
  pstn: {
    label: "PSTN caller",
    face: "🧑",
    how: "inbound call · your answer URL returned <Conference>"
  },
  out: {
    label: "Outbound call",
    face: "📞",
    how: "POST /Call/ with answer_url returning <Conference>"
  },
  sip: {
    label: "SIP endpoint",
    face: "🖥",
    how: "registered endpoint · caller_name is set for SIP legs"
  },
  rtc: {
    label: "WebRTC client",
    face: "🌐",
    how: "browser client · joined through the WebRTC SDK"
  }
};

export const SB_BASE = "/api/v1/Account/MA_XXXXXXXX/Conference/Sandbox";

export const SB_MAX = 8;

export const SB_ROOM = "Sandbox";

export const CvGuided = ({onScene}) => {
  const CV_PHASES = React.useMemo(cvPhases, []);
  const [branch, setBranch] = React.useState(null);
  const [step, setStep] = React.useState(0);
  const [playing, setPlaying] = React.useState(false);
  const seq = branch ? CV_SEQ.base.concat(CV_SEQ[branch]) : CV_SEQ.base;
  const idx = Math.min(step, seq.length - 1);
  const id = seq[idx];
  const ph = CV_PHASES[id];
  const atChoice = Boolean(ph.choice) && branch === null;
  const atEnd = idx === seq.length - 1 && !atChoice;
  React.useEffect(() => {
    if (!playing) return undefined;
    if (atChoice || atEnd) {
      setPlaying(false);
      return undefined;
    }
    const t = window.setTimeout(() => setStep(s => s + 1), 2600);
    return () => window.clearTimeout(t);
  }, [playing, idx, atChoice, atEnd]);
  const reset = () => {
    setPlaying(false);
    setBranch(null);
    setStep(0);
  };
  const fork = which => {
    if (atChoice) {
      setBranch(which);
      setStep(idx + 1);
    }
  };
  const back = () => {
    setPlaying(false);
    const n = Math.max(0, idx - 1);
    if (branch !== null && n < CV_SEQ.base.length) setBranch(null);
    setStep(n);
  };
  React.useEffect(() => {
    const onKey = e => {
      if (e.target && (/^(INPUT|SELECT|TEXTAREA)$/).test(e.target.tagName)) return;
      if (e.key === " " || e.key === "ArrowRight") {
        if (!atChoice && !atEnd) {
          e.preventDefault();
          setPlaying(false);
          setStep(s => s + 1);
        }
      } else if (e.key === "ArrowLeft") back(); else if (e.key === "1") fork("mod"); else if (e.key === "2") fork("api"); else if (e.key === "0") reset();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });
  const appApi = ph.lines.app === "api";
  const mem = (key, role, face, leg, state) => ({
    key,
    role,
    face,
    num: leg.num,
    mid: state === "standby" ? "" : leg.mid,
    state,
    stText: CV_STATES[state],
    flags: state === "standby" ? null : CV_FLAG_LINE[key],
    hot: key === "mod",
    cable: CV_CABLE_FOR[state]
  });
  React.useEffect(() => {
    onScene({
      members: [mem("b", "Participant B", "🧑‍🦱", CV_B, ph.m.b), mem("a", "Participant A", "🧑", CV_A, ph.m.a), mem("mod", "Moderator", "🧑‍💼", CV_MOD, ph.m.mod)],
      roomN: ph.room.n,
      roomSt: ph.room.st,
      roomName: CV_ROOM,
      roomLabel: CV_ROOM_TEXT[ph.room.st],
      server: {
        cable: ph.lines.app,
        state: appApi ? "live" : "ring",
        sub: "callbackUrl · REST API",
        text: appApi ? "sending a request" : "receiving webhooks"
      },
      empty: ""
    });
  }, [id]);
  const col = p => p.tone === "ok" ? "var(--cv-mint)" : p.tone === "bad" ? "var(--cv-rose)" : p.tone === "api" ? "var(--cv-ice)" : "var(--cv-sig)";
  return <div>
      <div className="cv-rail">
        <span>step <b>{pad(idx + 1)}</b></span>
        <span className="cv-count">{idx + 1} / {seq.length}</span>
        <span className="cv-track"><i style={{
    width: idx / (seq.length - 1) * 100 + "%"
  }} /></span>
      </div>
      <div className="cv-deck">
        <div className="cv-pane">
          <div className={"cv-stepcard" + (ph.tone ? " is-" + ph.tone : "")}>
            <div className="cv-cap"><span className="cv-n">{pad(idx + 1)}</span>{ph.tag}</div>
            <h3>{ph.title}</h3>
            <p>{ph.desc}</p>
          </div>
          {atChoice ? <div className="cv-choice">
              <button type="button" className="cv-modleaves" onClick={() => fork("mod")}>✕ Moderator hangs up</button>
              <button type="button" className="cv-apiends" onClick={() => fork("api")}>⌁ End it from your server</button>
            </div> : <button type="button" className="cv-cta" disabled={atEnd} onClick={() => {
    setPlaying(false);
    setStep(s => s + 1);
  }}>
              <span>{atEnd ? "Sequence complete" : ph.next || "Next"}</span><span aria-hidden="true">→</span>
            </button>}
          <div className="cv-util">
            <button type="button" onClick={() => {
    if (atEnd) {
      setBranch(null);
      setStep(0);
    }
    setPlaying(!playing);
  }}>{playing ? "❙❙ Pause" : "▶ Auto-play"}</button>
            <button type="button" onClick={back}>← Back</button>
            <button type="button" onClick={reset}>↻ Restart</button>
          </div>
          <div className="cv-hint">keys · <b>space</b> next · <b>1 / 2</b> moderator leaves or API ends · <b>0</b> restart</div>
          <div className="cv-logtitle">Sequence</div>
          <div className="cv-log">
            {seq.slice(0, idx + 1).map((pid, i) => <div key={pid} className={"cv-row" + (i === idx ? " is-cur" : "")}>
                <span className="cv-i">{pad(i + 1)}</span><span>{CV_PHASES[pid].title}</span><span className="cv-dot" style={{
    background: col(CV_PHASES[pid])
  }} />
              </div>).reverse()}
          </div>
        </div>
        <div className="cv-pane">
          <CvTerm dir={ph.dir} label={ph.label} route={ph.route} payload={ph.payload} outLabel={ph.outLabel} outKind={ph.outKind} out={ph.out} />
          <div className="cv-note">
            <b>Field names and values follow the Conference callback reference.</b> Numbers, UUIDs and hostnames are placeholders,
            and each payload is trimmed to the fields that matter. The account cap of 20 is the common default for
            <code>max_conf_members</code>; the XML <code>maxParticipants</code> attribute is parsed but does not override it.
          </div>
        </div>
      </div>
    </div>;
};

export const CV_CABLE_FOR = {
  standby: "standby",
  calling: "ring",
  hold: "hold",
  talk: "voice",
  muted: "muted",
  kicked: "kick",
  gone: "end",
  end: "end"
};

export const CV_FLAG_LINE = {
  a: ["startOnEnter=false · endOnExit=false", "stayAlone=true"],
  b: ["startOnEnter=false · endOnExit=false", "stayAlone=true"],
  mod: ["startOnEnter=true · endOnExit=true", "stayAlone=true"]
};

export const CvScene = ({members, roomN, roomSt, roomName, roomLabel, server, empty}) => {
  const ref = React.useRef(null);
  const [W, setW] = React.useState(1000);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const update = () => setW(Math.max(640, el.clientWidth));
    update();
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null;
    if (ro) ro.observe(el);
    return () => {
      if (ro) ro.disconnect();
    };
  }, []);
  const L = cvLayout(W, members.length);
  const circ = 2 * Math.PI * 73;
  const ringCable = p => {
    const dx = p.x - L.cx, dy = p.y - L.cy, d = Math.hypot(dx, dy) || 1;
    const x1 = L.cx + dx / d * (L.ring - 10), y1 = L.cy + dy / d * (L.ring - 10);
    const x2 = p.x - dx / d * 40, y2 = p.y - dy / d * 30;
    return `M${x1},${y1} L${x2},${y2}`;
  };
  const serverCable = `M${L.cx + L.ring - 10},${L.cy} L${L.sx - 60},${L.sy}`;
  return <div className="cv-scenewrap" ref={ref}>
      <div className="cv-scene" style={{
    height: L.H + "px"
  }}>
        <svg className="cv-cables" viewBox={`0 0 ${L.W} ${L.H}`} preserveAspectRatio="none">
          <g className={"cv-cg is-" + server.cable}>
            <path className="cv-glow" d={serverCable} /><path className="cv-cable" d={serverCable} />
            <circle className="cv-pulse" style={{
    offsetPath: `path('${serverCable}')`
  }} />
          </g>
          {members.map((m, i) => {
    const d = ringCable(L.pos(i));
    return <g key={m.key} className={"cv-cg is-" + m.cable}>
              <path className="cv-glow" d={d} /><path className="cv-cable" d={d} />
              <circle className="cv-pulse" style={{
      offsetPath: `path('${d}')`
    }} />
            </g>;
  })}
        </svg>

        <div className="cv-room" style={{
    left: L.cx + "px",
    top: L.cy + "px"
  }}>
          <div className={"cv-ring is-" + roomSt}>
            <svg viewBox="0 0 152 152"><circle className="cv-trk" cx="76" cy="76" r="73" /><circle className="cv-fil" cx="76" cy="76" r="73" strokeDasharray={`${roomN / CV_CAP * circ} ${circ}`} /></svg>
            <div><div className="cv-cnt">{roomN}<small> / {CV_CAP}</small></div><div className="cv-capl">members</div></div>
          </div>
          <div className="cv-rname"><span>room</span> {roomName}</div>
          <div className={"cv-rst on-" + roomSt}>{roomLabel}</div>
          {empty ? <div className="cv-sbempty">{empty}</div> : null}
        </div>

        {members.map((m, i) => {
    const p = L.pos(i);
    return <div key={m.key} className={"cv-node" + (m.selected ? " is-sel" : "") + (m.leaving ? " is-leaving" : "") + (m.onClick ? " is-click" : "")} style={{
      left: p.x + "px",
      top: p.y + "px"
    }} onClick={m.onClick} role={m.onClick ? "button" : undefined} tabIndex={m.onClick ? 0 : undefined}>
            <div className="cv-mod">
              <div className="cv-role"><span className={"cv-led on-" + m.state} />{m.role}</div>
              <div className={"cv-avatar is-" + m.state + (m.deaf ? " is-deaf" : "") + (m.playing ? " is-play" : "")}>{m.face}</div>
              <div className="cv-num">{m.num}</div>
              <div className="cv-mid">{m.mid ? <span>member <b>{m.mid}</b></span> : " "}</div>
              {m.flags ? <div className="cv-flagline">{m.flags.map((f, j) => <div key={j} className={j === 0 && m.hot ? "is-hot" : ""}>{f}</div>)}</div> : null}
              <div className={"cv-st on-" + m.state}>{m.stText}</div>
            </div>
          </div>;
  })}

        <div className="cv-node is-app" style={{
    left: L.sx + "px",
    top: L.sy + "px"
  }}>
          <div className="cv-mod">
            <div className="cv-role"><span className={"cv-led on-" + server.state} />Your server</div>
            <div className="cv-avatar is-server">⚙</div>
            <div className="cv-num">{CV_HOST}</div>
            <div className="cv-mid">{server.sub}</div>
            <div className={"cv-st on-" + server.state}>{server.text}</div>
          </div>
        </div>
      </div>
    </div>;
};

export const cvLayout = (W, n) => {
  const wide = W >= 1000;
  const crowded = n > 5;
  const column = !wide && n <= 4;
  const gap = CV_MEMBER_CARD_H + 14;
  const H = Math.round(column ? Math.max(W * 0.62, n * gap + 40) : W * (wide ? crowded ? 0.52 : 0.45 : crowded ? 0.95 : 0.85));
  const cx = W * (wide ? 0.4 : 0.45), cy = H * 0.5;
  const ring = 75;
  const rx = W * (wide ? 0.3 : 0.34);
  const ry = Math.max(0, cy - CV_MEMBER_CARD_H / 2 - 14);
  const span = Math.min(240, (wide ? 60 : 45) * (n - 1));
  const start = 180 - span / 2;
  const angle = i => (n === 1 ? 180 : start + span / (n - 1) * i) * Math.PI / 180;
  const pos = column ? i => ({
    x: Math.max(100, cx - rx),
    y: cy + (i - (n - 1) / 2) * gap,
    a: Math.PI
  }) : i => {
    const a = angle(i);
    return {
      x: cx + rx * Math.cos(a),
      y: cy + ry * Math.sin(a),
      a
    };
  };
  return {
    W,
    H,
    cx,
    cy,
    ring,
    pos,
    sx: W * 0.87,
    sy: cy
  };
};

export const CV_MEMBER_CARD_H = 176;

export const CvTerm = ({dir, label, route, payload, outLabel, outKind, out}) => <div>
    <div className="cv-term">
      <div className="cv-head">
        <span className={"cv-dir" + (dir === "out" ? " api" : "")}>{dir === "in" ? "Vobiz → your server" : "your server → Vobiz"}</span>
        <span>· {label}</span>
        <span className="cv-path">{route}</span>
      </div>
      {dir === "out" && payload && Object.keys(payload).length === 0 ? <pre><code className="cv-plain">{"{}  ← empty JSON body, auth headers only"}</code></pre> : <CvJson value={payload} />}
    </div>
    <div className="cv-term">
      <div className="cv-head"><span>{outLabel}</span></div>
      {outKind === "xml" ? <CvXml text={out} /> : outKind === "json" ? <CvJson value={out} /> : <pre><code className="cv-plain">{out}</code></pre>}
    </div>
  </div>;

export const CvXml = ({text}) => <pre><code>{text.split("\n").map((line, i) => {
  const t = line.trim();
  if (t.startsWith("<?xml") || t.startsWith("<!--")) return <div key={i} className="cv-decl">{line}</div>;
  const parts = line.split(/([\w:.-]+="[^"]*"|<\/?[A-Za-z][\w:.-]*|\/>|>)/g).filter(p => p !== "" && p !== undefined);
  return <div key={i}>{parts.map((p, j) => {
    if ((/^[\w:.-]+="/).test(p)) {
      const eq = p.indexOf("=");
      return <span key={j}><span className="cv-attr">{p.slice(0, eq)}</span>=<span className="cv-val">{p.slice(eq + 1)}</span></span>;
    }
    if ((/^<\/?[A-Za-z]/).test(p) || p === ">" || p === "/>") return <span key={j} className="cv-tag">{p}</span>;
    return <span key={j} className="cv-plain">{p}</span>;
  })}</div>;
})}</code></pre>;

export const CvJson = ({value}) => <pre><code>{JSON.stringify(value, null, 2).split("\n").map((line, i) => {
  const m = line.match(/^(\s*)"([^"]+)":\s?(.*)$/);
  if (!m) return <div key={i}>{line}</div>;
  const isNum = (/^-?\d+(\.\d+)?,?$/).test(m[3]);
  return <div key={i}>
        {m[1]}<span className="cv-k">"{m[2]}"</span>:{m[3] ? " " : ""}
        <span className={isNum ? "cv-nm" : "cv-v"}>{m[3]}</span>
      </div>;
})}</code></pre>;

export const pad = n => String(n).padStart(2, "0");

export const CV_FLAGS = {
  a: [["startOnEnter", "false"], ["endOnExit", "false"], ["stayAlone", "true"]],
  b: [["startOnEnter", "false"], ["endOnExit", "false"], ["stayAlone", "true"]],
  mod: [["startOnEnter", "true"], ["endOnExit", "true", "hot"], ["stayAlone", "true"]]
};

export const CV_ROOM_TEXT = {
  idle: "no room",
  wait: "waiting for moderator",
  live: "conference live",
  end: "room ended"
};

export const CV_STATES = {
  standby: "not yet joined",
  calling: "calling in",
  hold: "waiting on hold",
  talk: "in the room",
  muted: "muted",
  kicked: "being removed",
  gone: "left the room",
  end: "ended",
  ring: "ringing",
  live: "live"
};

export const CV_SEQ = {
  base: ["join", "first", "hold", "moderator", "third", "mute"],
  mod: ["modexit", "kicked", "end"],
  api: ["apiend", "exits", "end"]
};

export const cvPhases = () => ({
  join: {
    tag: "Inbound call",
    tone: "",
    title: "The first participant reaches your Vobiz number",
    desc: "Vobiz requests your answer URL and waits for XML. You return a Conference element with startConferenceOnEnter=\"false\", so this caller will wait for a moderator. stayAlone=\"true\" matters here: it initialises to false, and a lone member with stayAlone=\"false\" is disconnected.",
    m: {
      a: "calling",
      mod: "standby",
      b: "standby"
    },
    room: {
      n: 0,
      st: "idle"
    },
    lines: {
      a: "ring",
      mod: "standby",
      b: "standby",
      app: "hook"
    },
    dir: "in",
    label: "answer webhook",
    route: "POST /answer",
    payload: {
      Event: "StartApp",
      CallUUID: CV_A.uuid,
      From: CV_A.num,
      To: CV_DID,
      Direction: "inbound",
      CallStatus: "ringing"
    },
    outLabel: "the XML you return · participant",
    outKind: "xml",
    out: CV_XML_PART,
    next: "Join the room"
  },
  first: {
    tag: "First member",
    tone: "",
    title: "The room is created and the enter callback fires",
    desc: "A room named WeeklyStandup did not exist, so Vobiz creates it. The enter callback carries ConferenceFirstMember=\"true\" - that flag, not a separate start event, is how you know the room just came alive. Keep ConferenceMemberID: it is the handle for every member API.",
    m: {
      a: "hold",
      mod: "standby",
      b: "standby"
    },
    room: {
      n: 1,
      st: "wait"
    },
    lines: {
      a: "hold",
      mod: "standby",
      b: "standby",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · enter",
    route: "POST /conference-events",
    payload: cvEnter(CV_A, true),
    outLabel: "your response",
    outKind: "text",
    out: CV_NO_XML,
    next: "Play hold audio"
  },
  hold: {
    tag: "On hold",
    tone: "",
    title: "Vobiz fetches waitSound for the waiting participant",
    desc: "Because the room has not started, Vobiz asks your waitSound URL what the caller should hear. The request is identified by ConferenceAction=waitSound and carries the call fields but no member ID. Return Speak and Wait only.",
    m: {
      a: "hold",
      mod: "standby",
      b: "standby"
    },
    room: {
      n: 1,
      st: "wait"
    },
    lines: {
      a: "hold",
      mod: "standby",
      b: "standby",
      app: "hook"
    },
    dir: "in",
    label: "waitSound fetch",
    route: "POST /hold-music",
    payload: {
      ConferenceAction: "waitSound",
      Event: "ConferenceRemoteSounds",
      ConferenceName: CV_ROOM,
      CallUUID: CV_A.uuid,
      From: CV_A.num,
      To: CV_DID,
      Direction: "inbound",
      CallStatus: "in-progress"
    },
    outLabel: "the XML waitSound returns",
    outKind: "xml",
    out: CV_XML_HOLD,
    next: "Moderator dials in"
  },
  moderator: {
    tag: "Room starts",
    tone: "ok",
    title: "The moderator joins and the conference starts",
    desc: "The moderator's XML sets startConferenceOnEnter=\"true\", so hold audio stops and the two legs are bridged. It also sets endConferenceOnExit=\"true\" - remember that for later. Their enter callback arrives with ConferenceFirstMember=\"false\".",
    m: {
      a: "talk",
      mod: "talk",
      b: "standby"
    },
    room: {
      n: 2,
      st: "live"
    },
    lines: {
      a: "voice",
      mod: "voice",
      b: "standby",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · enter",
    route: "POST /conference-events",
    payload: cvEnter(CV_MOD, false),
    outLabel: "the XML you returned · moderator",
    outKind: "xml",
    out: CV_XML_MOD,
    next: "A third caller joins"
  },
  third: {
    tag: "Room grows",
    tone: "ok",
    title: "A second participant joins a room that is already live",
    desc: "Same participant XML as the first caller, but the room has started, so this leg is bridged immediately and never hears waitSound. The counter is against the account cap: max_conf_members is commonly 20. The XML maxParticipants attribute is parsed but does not lower it - enforce smaller rooms in your own app.",
    m: {
      a: "talk",
      mod: "talk",
      b: "talk"
    },
    room: {
      n: 3,
      st: "live"
    },
    lines: {
      a: "voice",
      mod: "voice",
      b: "voice",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · enter",
    route: "POST /conference-events",
    payload: cvEnter(CV_B, false),
    outLabel: "the XML you returned · participant",
    outKind: "xml",
    out: CV_XML_PART,
    next: "Mute a member from your server"
  },
  mute: {
    tag: "REST control",
    tone: "api",
    title: "Your server mutes a member by ConferenceMemberID",
    desc: "Member APIs take the room name and the member ID you captured from the enter callback. The member can still hear the room; use Deaf to stop that too. The ID can be a single member, a comma-separated list, or all.",
    m: {
      a: "talk",
      mod: "talk",
      b: "muted"
    },
    room: {
      n: 3,
      st: "live"
    },
    lines: {
      a: "voice",
      mod: "voice",
      b: "muted",
      app: "api"
    },
    choice: true,
    dir: "out",
    label: "mute member",
    route: "POST /Conference/WeeklyStandup/Member/12/Mute/",
    payload: {},
    outLabel: "Vobiz responds",
    outKind: "json",
    out: {
      message: "muted",
      member_id: ["12"],
      api_id: "API_REQUEST_ID"
    },
    next: ""
  },
  modexit: {
    tag: "Moderator leaves",
    tone: "bad",
    title: "The moderator hangs up - and takes the room with them",
    desc: "Their leg had endConferenceOnExit=\"true\". Vobiz sends their exit callback, then kicks every remaining member unconditionally. stayAlone on the other legs is not consulted: it only governs a member who becomes alone, not another member's exit.",
    m: {
      a: "kicked",
      mod: "gone",
      b: "kicked"
    },
    room: {
      n: 2,
      st: "live"
    },
    lines: {
      a: "kick",
      mod: "end",
      b: "kick",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · exit",
    route: "POST /conference-events",
    payload: cvExit(CV_MOD, false),
    outLabel: "your response",
    outKind: "text",
    out: CV_NO_XML,
    next: "Everyone is removed"
  },
  kicked: {
    tag: "Room empties",
    tone: "bad",
    title: "Two exit callbacks arrive, in no guaranteed order",
    desc: "Callbacks are dispatched asynchronously, so the two exits can arrive reversed. ConferenceLastMember=\"true\" means the room was empty at the instant that exit fired - not that this member left last. Correlate on CallUUID and ConferenceMemberID, never on arrival order. Kicked legs then continue with the XML after Conference.",
    m: {
      a: "gone",
      mod: "gone",
      b: "gone"
    },
    room: {
      n: 0,
      st: "end"
    },
    lines: {
      a: "end",
      mod: "end",
      b: "end",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · exit ×2",
    route: "POST /conference-events",
    payload: {
      "1_exit": cvExit(CV_B, false),
      "2_exit": cvExit(CV_A, true)
    },
    outLabel: "what runs next on each kicked leg",
    outKind: "xml",
    out: CV_XML_AFTER,
    next: "End the calls"
  },
  apiend: {
    tag: "REST control",
    tone: "api",
    title: "Your server ends the room with one request",
    desc: "DELETE on the conference name disconnects every member immediately. This is the clean way to end a room from your backend; it does not depend on who has endConferenceOnExit. The response is 204 with no body, and the room name returns 404 afterwards.",
    m: {
      a: "kicked",
      mod: "kicked",
      b: "kicked"
    },
    room: {
      n: 3,
      st: "live"
    },
    lines: {
      a: "kick",
      mod: "kick",
      b: "kick",
      app: "api"
    },
    dir: "out",
    label: "hang up conference",
    route: "DELETE /Conference/WeeklyStandup/",
    payload: {},
    outLabel: "Vobiz responds",
    outKind: "text",
    out: "HTTP 204 No Content\n\nAll members are disconnected from the room.",
    next: "Exit callbacks land"
  },
  exits: {
    tag: "Room empties",
    tone: "bad",
    title: "Three exit callbacks arrive, in no guaranteed order",
    desc: "One of them carries ConferenceLastMember=\"true\": the room was empty at the instant it fired. Timestamp events on receipt if you need true ordering. Each leg then continues with whatever XML followed its Conference element.",
    m: {
      a: "gone",
      mod: "gone",
      b: "gone"
    },
    room: {
      n: 0,
      st: "end"
    },
    lines: {
      a: "end",
      mod: "end",
      b: "end",
      app: "hook"
    },
    dir: "in",
    label: "callbackUrl · exit ×3",
    route: "POST /conference-events",
    payload: {
      "1_exit": cvExit(CV_MOD, false),
      "2_exit": cvExit(CV_B, false),
      "3_exit": cvExit(CV_A, true)
    },
    outLabel: "what runs next on each leg",
    outKind: "xml",
    out: CV_XML_AFTER,
    next: "End the calls"
  },
  end: {
    tag: "Calls ended",
    tone: "",
    title: "Each leg finishes its XML and the hangup webhooks land",
    desc: "Every leg was its own call, so you get one hangup webhook per leg. Legs removed by a platform teardown report NORMAL_CLEARING 4000 from the Callee, which looks like the participant hanging up - read your own exit log for the real reason. The hangup webhook is the authoritative end-of-call signal.",
    m: {
      a: "end",
      mod: "end",
      b: "end"
    },
    room: {
      n: 0,
      st: "idle"
    },
    lines: {
      a: "end",
      mod: "end",
      b: "end",
      app: "hook"
    },
    dir: "in",
    label: "hangup_url ×3",
    route: "POST /hangup",
    payload: {
      Event: "Hangup",
      CallUUID: CV_A.uuid,
      From: CV_A.num,
      To: CV_DID,
      CallStatus: "completed",
      Duration: "312",
      HangupCause: "NORMAL_CLEARING",
      HangupCauseCode: "4000",
      HangupSource: "Callee"
    },
    outLabel: "your response",
    outKind: "text",
    out: "HTTP 204 · the calls are over.\n\nThe room no longer exists. GET /Conference/WeeklyStandup/ now returns 404.",
    next: ""
  }
});

export const cvExit = (leg, last) => ({
  ConferenceAction: "exit",
  Event: "ConferenceExit",
  ConferenceUUID: CV_CONF_UUID,
  ConferenceName: CV_ROOM,
  ConferenceMemberID: leg.mid,
  ConferenceLastMember: last ? "true" : "false",
  CallUUID: leg.uuid
});

export const cvEnter = (leg, first) => ({
  ConferenceAction: "enter",
  Event: "ConferenceEnter",
  ConferenceUUID: CV_CONF_UUID,
  ConferenceName: CV_ROOM,
  ConferenceMemberID: leg.mid,
  ConferenceFirstMember: first ? "true" : "false",
  CallUUID: leg.uuid,
  From: leg.num,
  To: CV_DID,
  Direction: "inbound",
  CallStatus: "in-progress"
});

export const CV_NO_XML = "HTTP 204 · no XML expected.\n\nConference callbacks only notify you. They never steer the call.";

export const CV_XML_AFTER = `<!-- the rest of each participant's original document now runs -->
<Speak>The standup has ended. Goodbye.</Speak>
<Hangup/>`;

export const CV_XML_HOLD = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Speak>Please wait for the moderator.</Speak>
  <Wait length="5"/>
</Response>`;

export const CV_XML_MOD = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Conference startConferenceOnEnter="true"
              endConferenceOnExit="true"
              stayAlone="true"
              callbackUrl="https://your-server.example/conference-events"
              callbackMethod="POST">
    WeeklyStandup
  </Conference>
  <Hangup/>
</Response>`;

export const CV_XML_PART = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Speak>Please hold while we connect you to the standup.</Speak>
  <Conference startConferenceOnEnter="false"
              endConferenceOnExit="false"
              stayAlone="true"
              waitSound="https://your-server.example/hold-music"
              callbackUrl="https://your-server.example/conference-events"
              callbackMethod="POST">
    WeeklyStandup
  </Conference>
  <Speak>The standup has ended. Goodbye.</Speak>
  <Hangup/>
</Response>`;

export const CV_B = {
  num: "+14155550122",
  uuid: "9a0e0208-71bd-4c35-8e42-6db07f1a5c93",
  mid: "12"
};

export const CV_MOD = {
  num: "+14155559911",
  uuid: "c0a58e17-9d24-4f6b-8a13-72e5b4c9d806",
  mid: "11"
};

export const CV_A = {
  num: "+14155550111",
  uuid: "6f1d4a92-3b77-4d0e-9f2a-1c8b5e7d0431",
  mid: "10"
};

export const CV_DID = "+14155550100";

export const CV_CONF_UUID = "b7e2c4a0-5d1f-4e8a-9c3b-2f6d8a1e7c90";

export const CV_HOST = "your-server.example";

export const CV_CAP = 20;

export const CV_ROOM = "WeeklyStandup";

<ConferenceVisualizer />


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.