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

# WebRTC Widget

> Embed a Guava voice agent on your website with a single script tag

export const WidgetConfigurator = () => {
  const DEFAULT_NAME = "Guava";
  const DEFAULT_COLOR = "#4ecb8d";
  const hexToRgb = hex => {
    const n = parseInt(hex.replace("#", ""), 16);
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  };
  const rgbToHsl = (r, g, b) => {
    r /= 255;
    g /= 255;
    b /= 255;
    const max = Math.max(r, g, b), min = Math.min(r, g, b);
    const l = (max + min) / 2;
    if (max === min) return [0, 0, l * 100];
    const d = max - min;
    const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    let h = 0;
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6;
    return [h * 360, s * 100, l * 100];
  };
  const hslToRgb = (h, s, l) => {
    h /= 360;
    s /= 100;
    l /= 100;
    if (s === 0) {
      const v = Math.round(l * 255);
      return [v, v, v];
    }
    const hue2rgb = (p, q, t) => {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1 / 6) return p + (q - p) * 6 * t;
      if (t < 1 / 2) return q;
      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
      return p;
    };
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), Math.round(hue2rgb(p, q, h) * 255), Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
  };
  const hslHex = (h, s, l) => {
    const [r, g, b] = hslToRgb(h, s, l);
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
  };
  const hslRgba = (h, s, l, a) => {
    const [r, g, b] = hslToRgb(h, s, l);
    return `rgba(${r}, ${g}, ${b}, ${a})`;
  };
  const deriveColors = hex => {
    const rgb = hexToRgb(hex);
    const [h, s] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
    return {
      darkBase: hslHex(h, s, 26),
      accent: hex,
      accentRgba: a => `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`,
      vividCaption: hslHex(h, Math.min(s * 1.6, 95), 70),
      blob1: hslHex((h + 27) % 360, Math.max(s - 5, 0), 65),
      blob2: hslHex((h - 8 + 360) % 360, Math.max(s - 10, 0), 46),
      blob3: hslHex((h + 49) % 360, Math.max(s - 5, 0), 41),
      blob4: hslHex((h - 3 + 360) % 360, Math.min(s + 5, 100), 48),
      darkMultiply: hslRgba(h, 60, 15, 0.6),
      mediumBase: hslHex(h, Math.min(s * 1.1, 100), 32)
    };
  };
  const [name, setName] = useState("");
  const [color, setColor] = useState(DEFAULT_COLOR);
  const [enableChat, setEnableChat] = useState(false);
  const [pickerOpen, setPickerOpen] = useState(false);
  const configuratorRef = useRef(null);
  const areaRef = useRef(null);
  const dragging = useRef(false);
  useEffect(() => {
    if (!pickerOpen) return;
    const handler = e => {
      if (configuratorRef.current && !configuratorRef.current.contains(e.target)) {
        setPickerOpen(false);
      }
    };
    document.addEventListener("pointerdown", handler);
    return () => document.removeEventListener("pointerdown", handler);
  }, [pickerOpen]);
  const c = useMemo(() => deriveColors(color), [color]);
  const displayName = name || DEFAULT_NAME;
  const scriptTag = useMemo(() => {
    const attrs = [`  src="https://app.goguava.ai/static/build/webrtc-widgets/guava-widget.js"`, `  webrtc-code="grtc-YOUR_AGENT_CODE_HERE"`];
    const escapedName = name.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
    if (escapedName && escapedName !== DEFAULT_NAME) attrs.push(`  gw-name="${escapedName}"`);
    if (color !== DEFAULT_COLOR) attrs.push(`  gw-color="${color}"`);
    if (enableChat) attrs.push("  enable-chat");
    return `<script\n${attrs.join("\n")}\n></script>`;
  }, [name, color, enableChat]);
  const [cursorHue, , cursorLightness] = rgbToHsl(...hexToRgb(color));
  const pickColor = (clientX, clientY) => {
    const rect = areaRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
    setColor(hslHex(x * 360, 75, 20 + (1 - y) * 60));
  };
  const blobs = [{
    key: "1",
    bg: c.blob1,
    size: 140,
    offset: -20,
    spread: 60,
    dur: "8s",
    anim: "wc-drift-1"
  }, {
    key: "2",
    bg: c.blob2,
    size: 120,
    offset: -10,
    spread: 55,
    dur: "10s",
    anim: "wc-drift-2"
  }, {
    key: "3",
    bg: c.blob3,
    size: 130,
    offset: -15,
    spread: 55,
    dur: "12s",
    anim: "wc-drift-3"
  }, {
    key: "4",
    bg: c.blob4,
    size: 110,
    offset: -5,
    spread: 50,
    dur: "9s",
    anim: "wc-drift-4"
  }];
  const bubble = "max-w-[85%] px-3 py-2 rounded-xl text-[11px] leading-relaxed";
  return <div className="not-prose my-6" ref={configuratorRef}>
      <style>{`
        @keyframes wc-breathe {
          0%, 100% { transform: scale(1); filter: brightness(1); }
          50%      { transform: scale(1.03); filter: brightness(1.08); }
        }
        @keyframes wc-drift-1 {
          0%, 100% { transform: translate(0%, 0%) rotate(0deg); }
          25%      { transform: translate(15%, -10%) rotate(90deg); }
          50%      { transform: translate(-10%, 15%) rotate(180deg); }
          75%      { transform: translate(-15%, -5%) rotate(270deg); }
        }
        @keyframes wc-drift-2 {
          0%, 100% { transform: translate(0%, 0%) rotate(0deg); }
          25%      { transform: translate(-12%, 18%) rotate(-90deg); }
          50%      { transform: translate(18%, -8%) rotate(-180deg); }
          75%      { transform: translate(5%, 12%) rotate(-270deg); }
        }
        @keyframes wc-drift-3 {
          0%, 100% { transform: translate(0%, 0%) rotate(0deg); }
          33%      { transform: translate(12%, 12%) rotate(120deg); }
          66%      { transform: translate(-15%, -10%) rotate(240deg); }
        }
        @keyframes wc-drift-4 {
          0%, 100% { transform: translate(0%, 0%) rotate(0deg); }
          20%      { transform: translate(-10%, -15%) rotate(-72deg); }
          40%      { transform: translate(15%, 5%) rotate(-144deg); }
          60%      { transform: translate(-5%, 18%) rotate(-216deg); }
          80%      { transform: translate(12%, -12%) rotate(-288deg); }
        }
        @keyframes wc-drift-5 {
          0%, 100% { transform: translate(0%, 0%) scale(1); }
          30%      { transform: translate(20%, -15%) scale(1.1); }
          60%      { transform: translate(-18%, 20%) scale(0.9); }
          80%      { transform: translate(10%, 10%) scale(1.05); }
        }
      `}</style>

      <div className="rounded-2xl border border-gray-950/10 dark:border-white/10 p-5">
        <div className="flex flex-wrap items-end gap-4">
          <label className="flex flex-col gap-1.5" style={{
    width: 180
  }}>
            <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Name</span>
            <input type="text" placeholder={DEFAULT_NAME} value={name} onChange={e => setName(e.target.value)} className="h-8 w-full rounded-lg border border-gray-950/10 dark:border-white/10 bg-transparent px-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 outline-none focus:border-primary dark:focus:border-primary-light" />
          </label>

          <div className="flex flex-col gap-1.5">
            <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Color</span>
            <button type="button" aria-expanded={pickerOpen} onClick={() => setPickerOpen(!pickerOpen)} className="flex h-8 items-center gap-2 rounded-lg border border-gray-950/10 dark:border-white/10 px-2.5 hover:bg-gray-950/5 dark:hover:bg-white/5">
              <span className="shrink-0 rounded" style={{
    width: 16,
    height: 16,
    background: color,
    border: "1px solid rgba(127,127,127,0.25)"
  }} />
              <span className="font-mono text-xs text-gray-700 dark:text-gray-300">{color}</span>
            </button>
          </div>

          <div className="flex h-8 items-center gap-2">
            <button type="button" role="switch" aria-checked={enableChat} aria-label="Enable chat" onClick={() => setEnableChat(!enableChat)} className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${enableChat ? "bg-primary dark:bg-primary-light" : "bg-gray-300 dark:bg-gray-700"}`}>
              <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${enableChat ? "translate-x-[18px]" : "translate-x-0.5"}`} />
            </button>
            <span className="cursor-pointer select-none text-sm text-gray-700 dark:text-gray-300" onClick={() => setEnableChat(!enableChat)}>
              Enable chat
            </span>
          </div>
        </div>

        <div className="overflow-hidden transition-all duration-200 ease-in-out" style={{
    height: pickerOpen ? 116 : 0,
    marginTop: pickerOpen ? 16 : 0
  }}>
          <div ref={areaRef} className="relative cursor-crosshair select-none touch-none rounded-lg" style={{
    height: 100,
    background: `linear-gradient(to bottom, rgba(255,255,255,0.6), transparent 50%, rgba(0,0,0,0.6)),
                linear-gradient(to right, hsl(0,75%,50%), hsl(60,75%,50%), hsl(120,75%,50%), hsl(180,75%,50%), hsl(240,75%,50%), hsl(300,75%,50%), hsl(360,75%,50%))`
  }} onPointerDown={e => {
    dragging.current = true;
    e.currentTarget.setPointerCapture(e.pointerId);
    pickColor(e.clientX, e.clientY);
  }} onPointerMove={e => dragging.current && pickColor(e.clientX, e.clientY)} onPointerUp={() => {
    dragging.current = false;
  }}>
            <div className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2" style={{
    left: `${cursorHue / 360 * 100}%`,
    top: `${(1 - (cursorLightness - 20) / 60) * 100}%`,
    width: 14,
    height: 14,
    borderRadius: "50%",
    border: "2px solid white",
    boxShadow: "0 0 0 1px rgba(0,0,0,0.3), inset 0 0 0 1px rgba(0,0,0,0.3)",
    background: color
  }} />
          </div>
        </div>

        <div className="mt-5 flex items-center justify-center overflow-hidden rounded-xl bg-gray-950 transition-all duration-300 ease-in-out" style={{
    height: enableChat ? 380 : 180
  }}>
          <div className="flex items-center gap-5">
            {}
            <div className="flex select-none flex-col items-center p-4" style={{
    width: 160,
    borderRadius: "20px 20px 0 20px",
    border: `1px solid ${c.accentRgba(0.2)}`,
    background: "rgba(22, 27, 34, 0.55)",
    backdropFilter: "blur(12px)",
    boxShadow: "0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)"
  }}>
              <div className="relative shrink-0" style={{
    width: 72,
    height: 72,
    borderRadius: "50%",
    boxShadow: `0 0 10px ${c.accentRgba(0.35)}`
  }}>
                <div className="relative h-full w-full overflow-hidden rounded-full" style={{
    background: c.darkBase,
    maskImage: "radial-gradient(circle, #fff 100%, transparent 100%)",
    WebkitMaskImage: "radial-gradient(circle, #fff 100%, transparent 100%)",
    animation: "wc-breathe 4s ease-in-out infinite"
  }}>
                  {blobs.map(b => <div key={b.key} className="absolute rounded-full" style={{
    width: `${b.size}%`,
    height: `${b.size}%`,
    top: `${b.offset}%`,
    left: `${b.offset}%`,
    background: `radial-gradient(circle, ${b.bg} 0%, transparent ${b.spread}%)`,
    mixBlendMode: "screen",
    filter: "blur(6px)",
    animation: `${b.anim} ${b.dur} ease-in-out infinite`
  }} />)}
                  <div className="absolute inset-0 rounded-full" style={{
    background: `radial-gradient(ellipse at 50% 50%, ${c.darkMultiply} 0%, transparent 65%)`,
    mixBlendMode: "multiply",
    filter: "blur(8px)",
    animation: "wc-drift-5 11s ease-in-out infinite"
  }} />
                </div>
                <div className="pointer-events-none absolute inset-0 rounded-full" style={{
    backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E")`,
    backgroundSize: "cover",
    mixBlendMode: "overlay",
    opacity: 0.18
  }} />
                <div className="pointer-events-none absolute rounded-full" style={{
    top: "12%",
    left: "18%",
    width: "38%",
    height: "28%",
    background: "radial-gradient(ellipse, rgba(255,255,255,0.55) 0%, transparent 70%)"
  }} />
              </div>
              <div className="mt-3 break-words text-center text-[11px] font-semibold uppercase tracking-widest" style={{
    color: c.vividCaption,
    overflowWrap: "break-word"
  }}>
                Talk to {displayName}
              </div>
            </div>

            {}
            <div className="overflow-hidden transition-all duration-300 ease-in-out" style={{
    width: enableChat ? 260 : 0,
    opacity: enableChat ? 1 : 0
  }}>
              <div className="flex flex-col overflow-hidden" style={{
    width: 260,
    height: 340,
    borderRadius: 16,
    background: "#111",
    border: "1px solid #222",
    boxShadow: "0 16px 48px rgba(0,0,0,0.6)"
  }}>
                <div className="flex shrink-0 items-center gap-2.5 px-4 py-3" style={{
    background: "#161b22",
    borderBottom: "1px solid #222"
  }}>
                  <div className="relative shrink-0 overflow-hidden rounded-full" style={{
    width: 28,
    height: 28,
    background: c.darkBase,
    boxShadow: `0 0 6px ${c.accentRgba(0.3)}`
  }}>
                    <div className="absolute rounded-full" style={{
    width: "140%",
    height: "140%",
    top: "-20%",
    left: "-20%",
    background: `radial-gradient(circle, ${c.blob1} 0%, transparent 60%)`,
    mixBlendMode: "screen",
    filter: "blur(4px)"
  }} />
                  </div>
                  <div className="min-w-0 flex-1">
                    <div className="truncate text-xs font-semibold text-white">{displayName}</div>
                    <div className="mt-0.5 flex items-center gap-1.5">
                      <span className="shrink-0 rounded-full" style={{
    width: 5,
    height: 5,
    background: c.accent
  }} />
                      <span className="text-[11px]" style={{
    color: c.accent
  }}>Connected</span>
                      <span className="text-[11px] tabular-nums" style={{
    color: "#555"
  }}>0:42</span>
                    </div>
                  </div>
                </div>
                <div className="flex flex-1 flex-col gap-2 overflow-hidden p-3.5">
                  <div className={`${bubble} self-start rounded-bl`} style={{
    background: "#1a1f2b",
    color: "#ccc"
  }}>
                    Hi! How can I help you today?
                  </div>
                  <div className={`${bubble} self-end rounded-br`} style={{
    background: c.darkBase,
    color: "#e8e8e8"
  }}>
                    I have a question about pricing.
                  </div>
                  <div className={`${bubble} self-start rounded-bl`} style={{
    background: "#1a1f2b",
    color: "#ccc"
  }}>
                    Of course! I'd be happy to walk you through our plans.
                  </div>
                </div>
                <div className="flex shrink-0 items-center gap-1.5 px-3 py-2.5" style={{
    background: "#161b22",
    borderTop: "1px solid #222"
  }}>
                  <div className="flex shrink-0 items-center justify-center rounded-lg" style={{
    width: 30,
    height: 30,
    background: c.mediumBase,
    boxShadow: `0 0 8px ${c.accentRgba(0.25)}`
  }}>
                    <svg viewBox="0 0 24 24" fill="white" width={14} height={14}>
                      <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z" />
                      <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
                    </svg>
                  </div>
                  <div className="flex-1 rounded-md px-2.5 py-1.5 text-[11px]" style={{
    background: "#0d1117",
    border: "1px solid #222",
    color: "#555"
  }}>
                    Type a message...
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <CodeBlock filename="index.html" language="html">
        {scriptTag}
      </CodeBlock>
    </div>;
};

Embed a Guava voice agent on your website so visitors can start a voice conversation directly from the browser. The WebRTC audio widget is a drop-in `<script>` tag that handles all WebRTC signaling, UI, and state management — no additional CSS, JS, or dependencies required.

## Integration

Add a single `<script>` tag to your page. The only required attribute is `webrtc-code`, which is the agent code (starts with `grtc-`) obtained from the Guava dashboard.

```html index.html theme={null}
<script
    src="https://app.goguava.ai/static/build/webrtc-widgets/guava-widget.js"
    webrtc-code="grtc-YOUR_AGENT_CODE_HERE"
  ></script>
```

<Info>
  **Self-contained.** The widget injects its own HTML and CSS with no global variables or style conflicts. Works on any page with a modern browser — just add the script tag and configure with attributes.
</Info>

## Customization

| Attribute | Type | Default | Description |
| - | - | - | - |
| `gw-name` | string | `"Guava"` | Display name shown on the widget. |
| `gw-color` | string | `"#4ecb8d"` | Accent color as a 6-digit hex code (e.g. `"#4ecb8d"` or `"4ecb8d"`). |
| `enable-chat` | flag | off | Add this attribute to enable the chat panel with message transcript, text input, and mic toggle. |

## Try it

Configure the widget and copy the script tag for your site.

<WidgetConfigurator />

## Chat panel

The chat panel adds a full conversation interface when the user clicks the orb:

* Live message transcript (agent and caller speech)
* Text input for sending messages during the call
* Mic toggle to mute/unmute
* Call timer and hang-up button
* Draggable header to reposition the panel
* Post-call "New conversation" button to start another session

## Generating a WebRTC code via SDK

<Tabs>
  <Tab title="Python">
    Instead of obtaining a WebRTC code from the Guava dashboard, you can generate one programmatically with `client.create_webrtc_agent()`. This is useful when you need to create codes on-the-fly or control their TTL.

    ```python generate_code.py theme={null}
    from datetime import timedelta
    import guava

    client = guava.Client(api_key="your-api-key")

    # Create a WebRTC code valid for 1 hour
    webrtc_code = client.create_webrtc_agent(ttl=timedelta(hours=1))
    print(f"WebRTC code: {webrtc_code}")

    # Use the code to listen for inbound calls
    agent = guava.Agent(name="Hannah", purpose="You are a helpful voice agent.")
    agent.listen_webrtc(webrtc_code)
    ```

    | Parameter | Type | Required | Description |
    | - | - | - | - |
    | `ttl` | `datetime.timedelta \| None` | No | How long the WebRTC code should remain valid. If omitted, the server default TTL applies. |

    **Returns:** `str` — a WebRTC code (e.g. `grtc-...`) that can be passed to [`agent.listen_webrtc()`](/sdk-reference/agent) or used as the widget's `webrtc-code` attribute.

    Raises an HTTP error if the API request fails.
  </Tab>

  <Tab title="TypeScript">
    Instead of obtaining a WebRTC code from the Guava dashboard, you can generate one programmatically with `client.createWebrtcAgent()`. This is useful when you need to create codes on-the-fly or control their TTL.

    ```typescript generate_code.ts theme={null}
    import * as guava from "@guava-ai/guava-sdk";

    const client = new guava.Client({ apiKey: "your-api-key" });

    // Create a WebRTC code valid for 1 hour (3600 seconds)
    const webrtcCode = await client.createWebrtcAgent(3600);
    console.log(`WebRTC code: ${webrtcCode}`);

    // Use the code to listen for inbound calls
    const agent = new guava.Agent({ name: "Hannah", purpose: "You are a helpful voice agent." });
    agent.listenWebrtc(webrtcCode);
    ```

    | Parameter | Type | Required | Description |
    | - | - | - | - |
    | `ttlSec` | `number` | No | How long the WebRTC code should remain valid, in seconds. If omitted, the server default TTL applies. |

    **Returns:** `Promise<string>` — a WebRTC code (e.g. `grtc-...`) that can be passed to [`agent.listenWebrtc()`](/sdk-reference/agent) or used as the widget's `webrtc-code` attribute.

    Throws an error if the API request fails.
  </Tab>
</Tabs>


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