# React

> Hooks for React and React Native over the client SDK — a conversation in a component, and the person's sessions, facts and relationships.

`@lumavox/react` is hooks over [the client SDK](https://docs.lumavox.ai/sdks/client.md), for React and React Native alike: a conversation that opens with a component and closes with it, and reads of what the REST routes hold. React 18 or later; no other dependency.

```bash title="npm"
npm install @lumavox/react @lumavox/client
```

Put a client in a provider, and talk to an agent from any component below it:

```tsx title="TSX"
import { createClient } from "@lumavox/client";
import { AgentProvider, useAgent } from "@lumavox/react";

const agents = createClient({
  baseUrl: "https://api.lumavox.ai",
  getToken: async () => (await fetch("/api/agent-key", { method: "POST" })).text(),
});

export function App() {
  return (
    <AgentProvider client={agents}>
      <Chat agentId="agt_..." />
    </AgentProvider>
  );
}

function Chat({ agentId }: { agentId: string }) {
  const agent = useAgent({ agentId });
  return (
    <>
      <p>{agent.turn?.text}</p>
      <button disabled={agent.state === "connecting"} onClick={() => agent.send("Hello!")}>
        Say hello
      </button>
    </>
  );
}
```

Make the client once, outside any component. Every hook also takes `client` directly, for an app with no provider.

## `useAgent`

```ts title="TypeScript"
useAgent({ agentId, resume, title }, options);     // the person's last conversation with it, or a new one; resume and title optional
useAgent({ sessionId }, options);                  // one you opened
useAgent(null, options);                           // nobody yet: nothing is opened
```

It opens the session on mount, connects, and closes when the component goes; a new target opens a new conversation. The options are [the client's handlers](https://docs.lumavox.ai/sdks/client.md#a-conversation) — `onText`, `onDone` and the rest — kept in a ref, so a new function on every render never redials; and `speech`, `thoughts`, `tools`, `client`, `audio`, and `onAudioInterrupted` and `onAudioResumed` for [an interruption](#audio).

| It returns | |
|---|---|
| `state`, `ready`, `turn`, `caption`, `relation`, `queued` | the conversation's snapshot, re-rendered on every change |
| `session` | the session it opened, with its last messages, for a transcript to start from |
| `error` | what went wrong last — `{ kind, message }`, its kind `session`, `frame`, `closed` or `audio` — cleared by the next turn |
| `speaking`, `recording` | the player is playing; the recorder is open |
| `interrupted` | the device has the audio — a call, Siri, another app: playback is paused |
| `send`, `startAudio`, `sendAudio`, `endAudio`, `cancel`, `mute`, `reconnect`, `close` | the conversation's own |
| `startRecording`, `stopRecording` | recording through `audio` |
| `newSession()` | a new conversation with the same agent |
| `conversation` | the client's `Conversation` itself |

A send made before the conversation exists waits for it. A session that answers after the component is gone is never connected.

**Your app's tools** are `tools`, as [the client's](https://docs.lumavox.ai/sdks/client.md#your-apps-tools): named when the conversation connects, and each call runs the latest render's handler.

```ts title="TypeScript"
const agent = useAgent({ agentId }, {
  tools: {
    open_page: ({ page }) => {
      navigate(String(page));
      return { opened: page };
    },
  },
});
```

## Audio

`audio` is an `AgentAudio` — a player and a recorder in one object, yours to make and to dispose. With one, every reply is played as it arrives, and playback stops on `cancel`, `mute`, a new message or a spoken turn, for the rest of that reply's frames too. `startRecording({ rate, transcribeOnly })` opens the recorder first and the recording at the rate it was granted, so a refused microphone never opens one — and takes a turn's own `speech` and `thoughts`, as `send` does; `stopRecording({ transcribeOnly })` takes the turn, or answers just the words, and `stopRecording({ discard: true })` throws the recording away. A dictation (`transcribeOnly`) leaves the reply playing.

```ts title="TypeScript"
interface AgentAudio {
  play(pcm: ArrayBuffer, frame: { sample_rate: number; channels: number; chunk: number; turn: string }): void;
  stop(): void;
  onDrained(fn: () => void): () => void;
  startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: { rate?: number }): Promise<{ rate: number }>;
  stopCapture(): Promise<void>;
  dispose(): void;
  onInterruption?(fn: (interrupted: boolean) => void): () => void;
  handsFree?(): Promise<{ ok: true } | { ok: false; reason: string }>; // for listen()
  duck?(on: boolean): void; // the reply quieter while the person may be talking over it
}
```

**An interruption**, when the adapter tells one — a phone call, Siri, another app, the app leaving the screen — throws away a recording in progress as `stopRecording({ discard: true })` would, calls `onAudioInterrupted`, and sets `interrupted` until the adapter says the audio is back (`onAudioResumed`). The adapter pauses its own playback meanwhile.

In a browser, [the client's](https://docs.lumavox.ai/sdks/client.md#audio-in-a-browser) `createWebAudio()` is one. Make it once, and call its `prime()` in the click that starts the conversation — browsers start sound only from a gesture:

```tsx title="TSX"
import { createWebAudio } from "@lumavox/client/web-audio";
import { useAgent } from "@lumavox/react";
import { useMemo, useState } from "react";

function Talk({ agentId }: { agentId: string }) {
  const audio = useMemo(() => createWebAudio(), []);
  const [started, setStarted] = useState(false);
  const agent = useAgent(started ? { agentId } : null, { audio });
  if (!started) {
    return (
      <button
        onClick={() => {
          void audio.prime();
          setStarted(true);
        }}
      >
        Start
      </button>
    );
  }
  return (
    <button onPointerDown={() => agent.startRecording()} onPointerUp={() => agent.stopRecording()}>
      {agent.recording ? "Listening…" : "Hold to talk"}
    </button>
  );
}
```

It records at 24 kHz whatever `startRecording` asks. On React Native, [`@lumavox/react-native`](https://docs.lumavox.ai/sdks/react-native.md) is one install with these hooks and `createAudio()`, on iOS, Android and the web. Without `audio` or an `onAudio` handler, no speech is asked for — nothing would play it.

To draw against the voice — a face lip-synced, subtitles — subscribe to the audio you gave the hook, not to the hook: `createWebAudio()` and `createAudio()` tell every frame as it is queued, with when it starts on the player's clock, and what is heard now ([the clock](https://docs.lumavox.ai/sdks/client.md#the-clock-a-face-subtitles)). The hook plays through them as before.

## Hands-free

`agent.listen()` leaves the recorder open and turns every utterance into a turn, as [the client's `listen`](https://docs.lumavox.ai/sdks/client.md#hands-free) does: 700 ms of silence ends one, and talking over the agent stops its voice once its first words are heard — for the rest of that reply. `listening` says it is on, `hearing` that someone is talking; `stopListening()` closes the recorder. Push-to-talk waits while it listens.

```tsx title="TSX"
import { createWebAudio } from "@lumavox/client/web-audio";
import { useAgent } from "@lumavox/react";
import { useMemo } from "react";

function HandsFree({ agentId }: { agentId: string }) {
  const audio = useMemo(() => createWebAudio(), []);
  const agent = useAgent({ agentId }, { audio });
  return (
    <button
      onClick={() => {
        void audio.prime();
        void (agent.listening ? agent.stopListening() : agent.listen());
      }}
    >
      {agent.hearing ? "Hearing you…" : agent.listening ? "Listening — tap to stop" : "Talk hands-free"}
    </button>
  );
}
```

`listen()` answers whether it listens. Where the agent would hear itself — a browser that cannot cancel echo, an iPhone, or an audio adapter with no `handsFree` — it does not, and `error` says why, in words to show; push-to-talk works there as ever. `listen({ assumeNoEcho: true })` when you know better: headphones on, say.

**`listen({ halfDuplex: true })` runs everywhere**: the recorder stays open, but nothing is heard while the agent speaks, so it never hears itself — and talking never stops it ([half-duplex](https://docs.lumavox.ai/sdks/client.md#half-duplex)). Offer it where `listen()` refused:

```tsx title="TSX"
async function talk(agent: ReturnType<typeof useAgent>) {
  if (!(await agent.listen())) await agent.listen({ halfDuplex: true });
}
```

## Reading

Each returns `{ data, error, loading, refresh }`, with `error` a `PlatformError`. There is no shared cache: two components reading the same thing read it twice. On TanStack Query or SWR, call `client.sessions`, `client.facts` and `client.relations` in your own queries instead.

| Hook | |
|---|---|
| `useSessions({ limit, cursor })` | a page of the person's conversations, most recent first, with `nextCursor` — pass it as `cursor` for the next page; `null` on the last |
| `useSession(id)` | one conversation and its latest messages, with `messages` (every one read so far, oldest first), `hasOlder` and `loadOlder()`, which reads back by the transcript's cursor |
| `useFacts()` | what is remembered about the person — an empty list when nothing is — with `forget(id)` and `forgetAll()` |
| `useRelation(agentId)` | how one agent feels about them: live from a `useAgent` talking to it under the same provider, read once otherwise |
