# Agent Orb

> Glassy liquid sphere for voice agents, drawn in a shader: breathes when idle, reacts to mic and voice levels, swirls while thinking.

- **Slug:** `agent-orb` · **Category:** voice · **Status:** beta
- **Import:** `import { AgentOrb } from "@kura-ui/react/agent-orb";`
- **Docs:** https://kuro.abstergo.fyi/components/agent-orb
- **Registry item:** https://kuro.abstergo.fyi/r/agent-orb.json
- **Tags:** voice, orb, agent, audio, webgl, shader, assistant, speaking, listening

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/agent-orb.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add agent-orb
# npm package
npm install @kura-ui/react
```

## When to use

- The central visual of a voice agent or call screen: idle, listening, thinking, speaking, error.
- Showing that an assistant is hearing the user (mic level) or talking back (TTS level).
- A 'talk to the agent' entry point inside a button or a card.
- An ambient, branded presence for an AI assistant in an empty state.

## When not to use

- Precise audio feedback such as a recording meter or waveform — use audio-waveform or voice-dictation.
- Text chat 'thinking' feedback inside a message list — use thinking-indicator or shimmer-text.
- A full-bleed background — use mesh-gradient or aurora.
- More than one per view: the orb is the accent element; repeat it and it stops meaning anything.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `state` | `"idle" \| "listening" \| "thinking" \| "speaking" \| "error"` | `"idle"` | no | What the agent is doing. idle breathes slowly, listening ripples with the input level, thinking swirls, speaking pulses with the output level, error turns red and calms down. |
| `colors` | `"jade" \| "ocean" \| "amber" \| "graphite" \| readonly [string, string, string, string]` | `"jade"` | no | A palette preset, or four CSS colours `[deep, mid, bright, glint]` (OKLCH, hex, `var(--token)`…). jade is the Kura signal colour. |
| `analyser` | `AudioAnalyser` | — | no | From `useAudioAnalyser`. Its level drives the orb while listening or speaking. Read every frame on the GPU loop — no React re-renders. |
| `inputLevel` | `number` | — | no | Microphone level 0–1 used while `listening`. Overrides `analyser`. |
| `outputLevel` | `number` | — | no | Agent voice level 0–1 used while `speaking`. Overrides `analyser`. |
| `label` | `string` | — | no | Accessible text for the state. Default: Voice agent idle / Listening / Thinking / Speaking / Voice agent error. |
| `announce` | `boolean` | `true` | no | Render the label in a polite live region. Set false when several orbs are on screen or a parent already announces the state. |
| `maxDpr` | `number` | `2` | no | Device-pixel-ratio cap. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **AgentOrb** (`data-slot="agent-orb"`) — Square root (`aspect-square`); size it with a width class. `data-state` mirrors `state`.
- **ShaderCanvas** (`data-slot="agent-orb-canvas"`) — The WebGL sphere and its halo (aria-hidden). The sphere fills ~70% of the box.
- **Fallback** (`data-slot="agent-orb-fallback"`) — CSS gradient sphere used when WebGL2 is unavailable; it still spins while thinking.

## Accessibility

- Role: `status`
- The canvas is decorative (aria-hidden). The state is exposed as visually hidden text in a role=status live region, announced politely when it changes.
- Audio levels are never announced. Pass `label` to describe custom situations (e.g. “Muted”).
- If the orb is inside a button, give the button its own aria-label and set `announce={false}` to avoid double announcements.
- Reduced motion: No breathing, swirl, flow or audio reaction: a still frame per state. State changes cross-fade colour and brightness over ~0.4s, then rendering stops.

## Notes for agents

- Give it a width (`className="size-48"` or `w-full max-w-64`); it is square. The sphere fills ~70% so the halo has room — do not clip it with overflow-hidden.
- For real audio: `const mic = useAudioAnalyser({ source: micStream })` for listening and a second analyser on the TTS <audio> element for speaking; switch `analyser` with the state. For demos use `useAudioAnalyser({ simulate: "speech" })`.
- `inputLevel`/`outputLevel` are fine for levels you already have (e.g. from a WebRTC SDK) at ≤ 30 updates/s; the shader smooths them.
- Changing `state` never restarts the animation: uniforms ease toward the new state, so you can flip states as fast as your agent emits events.
- Only one orb per view should be animated; for lists of agents use a static avatar.

## Examples

### Idle orb (minimal)

Size it with a class; it breathes slowly.

```tsx
import { AgentOrb } from "@kura-ui/react/agent-orb";

export function AgentOrbMinimal() {
  return <AgentOrb className="size-48" />;
}
```

### States, palettes and audio (full)

Every state and preset, driven by a simulated speech analyser.

```tsx
"use client";

import * as React from "react";
import { useAudioAnalyser } from "@kura-ui/react/hooks";
import { Button } from "@kura-ui/react/button";
import { AgentOrb, type AgentOrbPreset, type AgentOrbState } from "@kura-ui/react/agent-orb";

const states: AgentOrbState[] = ["idle", "listening", "thinking", "speaking", "error"];

const presets: AgentOrbPreset[] = ["jade", "ocean", "amber", "graphite"];

export function AgentOrbFull() {
  const [state, setState] = React.useState<AgentOrbState>("speaking");
  const [preset, setPreset] = React.useState<AgentOrbPreset>("jade");
  // Simulated audio for the demo; pass `source: micStream` in a real app.
  const analyser = useAudioAnalyser({ simulate: "speech" });
  return (
    <div className="flex w-full max-w-md flex-col items-center gap-5">
      <AgentOrb state={state} colors={preset} analyser={analyser} className="size-64" />
      <div
        role="radiogroup"
        aria-label="Agent state"
        className="flex flex-wrap justify-center gap-1"
      >
        {states.map((s) => (
          <Button
            key={s}
            size="sm"
            variant={s === state ? "secondary" : "ghost"}
            role="radio"
            aria-checked={s === state}
            onClick={() => setState(s)}
            className="capitalize"
          >
            {s}
          </Button>
        ))}
      </div>
      <div role="radiogroup" aria-label="Palette" className="flex gap-1">
        {presets.map((p) => (
          <Button
            key={p}
            size="xs"
            variant={p === preset ? "secondary" : "ghost"}
            role="radio"
            aria-checked={p === preset}
            onClick={() => setPreset(p)}
            className="capitalize"
          >
            {p}
          </Button>
        ))}
      </div>
    </div>
  );
}
```

### All five states (state)

idle, listening, thinking, speaking and error side by side.

```tsx
"use client";

import { useAudioAnalyser } from "@kura-ui/react/hooks";
import { AgentOrb, type AgentOrbState } from "@kura-ui/react/agent-orb";

const states: AgentOrbState[] = ["idle", "listening", "thinking", "speaking", "error"];

export function AgentOrbStates() {
  const analyser = useAudioAnalyser({ simulate: "speech" });
  return (
    <div className="grid w-full grid-cols-5 gap-2">
      {states.map((s) => (
        <figure key={s} className="flex flex-col items-center gap-1">
          <AgentOrb state={s} analyser={analyser} announce={false} className="w-full max-w-32" />
          <figcaption className="text-kura-fg-subtle text-xs capitalize">{s}</figcaption>
        </figure>
      ))}
    </div>
  );
}
```

### Voice session card (composition)

A scripted call with captions, ShimmerText while thinking and IconButton controls.

```tsx
"use client";

import { Mic, MicOff, PhoneOff } from "lucide-react";
import * as React from "react";
import { useAudioAnalyser } from "@kura-ui/react/hooks";
import { IconButton } from "@kura-ui/react/icon-button";
import { ShimmerText } from "@kura-ui/react/shimmer-text";
import { AgentOrb, type AgentOrbState } from "@kura-ui/react/agent-orb";

const script: Array<{ state: AgentOrbState; ms: number; caption: string }> = [
  { state: "listening", ms: 3200, caption: "Listening…" },
  { state: "thinking", ms: 2400, caption: "Checking your calendar…" },
  { state: "speaking", ms: 4200, caption: "You're free after 3pm on Thursday." },
];

export function AgentOrbVoiceSession() {
  const [step, setStep] = React.useState(0);
  const [muted, setMuted] = React.useState(false);
  const analyser = useAudioAnalyser({ simulate: "speech" });
  const current = script[step % script.length]!;
  React.useEffect(() => {
    const id = setTimeout(() => setStep((s) => s + 1), current.ms);
    return () => clearTimeout(id);
  }, [current]);
  const state: AgentOrbState = muted && current.state === "listening" ? "idle" : current.state;
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-kura-2xl bg-kura-surface p-6 shadow-kura-md">
      <AgentOrb
        state={state}
        analyser={analyser}
        label={muted ? "Muted" : undefined}
        className="size-44"
      />
      <p className="flex h-10 items-center text-center text-kura-fg-muted text-sm" aria-hidden>
        {state === "thinking" ? <ShimmerText>{current.caption}</ShimmerText> : current.caption}
      </p>
      <div className="flex items-center gap-2">
        <IconButton
          label={muted ? "Unmute" : "Mute"}
          variant="secondary"
          size="lg"
          aria-pressed={muted}
          icon={muted ? <MicOff /> : <Mic />}
          onClick={() => setMuted((m) => !m)}
        />
        <IconButton label="End call" variant="danger" size="lg" icon={<PhoneOff />} />
      </div>
    </div>
  );
}
```

## Dependencies

- npm: none
- Kura registry items: `kura-theme`, `kura-merge-refs`, `kura-shader-color`, `shader-canvas`, `use-audio-analyser`, `use-reduced-motion`, `utils`
- Related: `voice-dictation`, `audio-waveform`, `thinking-indicator`, `mesh-gradient`, `shader-canvas`
