diff --git a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md index 35f867d0..5b2b2e92 100644 --- a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md +++ b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md @@ -30,7 +30,7 @@ Ask concise follow-ups only when these are missing. ```bash python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \ - --path text-to-speech/nextjs/my-example + --path text-to-speech/expo/my-example ``` Add `--with-assets` when the example should ship sample files, or `--reference ` to copy from a specific existing example. @@ -43,22 +43,24 @@ Add `--with-assets` when the example should ship sample files, or `--reference < - sections are file-by-file using `## \`path/to/file\`` - bullets call out concrete SDKs, env handling, models, voice IDs, UI states, and error handling - do not restate repo preamble like `example/`-only rules or `DESIGN.md`; the generator adds that +- for `expo`, assume the shared template already provides the generic Expo Router shell, server-ready web config, and baseline verification scripts; keep the prompt focused on ElevenLabs-specific UI and `+api.ts` work 7. Keep `setup.sh` aligned with current patterns: - use `set -euo pipefail` - derive `DIR` and `REPO_ROOT` -- clean `example/` but preserve cache dirs (`node_modules`, `.venv`, `.next`) when relevant +- clean `example/` but preserve cache dirs (`node_modules`, `.venv`, `.next`, `.expo`) when relevant - seed from `templates//` - copy `README.md` into `example/README.md` - copy `assets/` and local `.env` only when present - install dependencies at the end - for `nextjs`, fetch latest ElevenLabs package versions at setup time and patch `package.json` +- for `expo`, keep the shared template generic and server-capable so `PROMPT.md` only needs to describe the ElevenLabs integration 8. Keep `README.md` aligned with the closest current reference: - always include a heading, one-sentence summary, `## Setup`, and `## Run` -- add `## Usage` for interactive examples such as Next.js and agents demos +- add `## Usage` for interactive examples such as Next.js, Expo, and agents demos - commands should work from inside `example/` 9. Recommended when shipping the example: add it to the root `README.md`. diff --git a/.cursor/skills/scaffold-elevenlabs-example/reference.md b/.cursor/skills/scaffold-elevenlabs-example/reference.md index ccb0e92d..d5ef60dc 100644 --- a/.cursor/skills/scaffold-elevenlabs-example/reference.md +++ b/.cursor/skills/scaffold-elevenlabs-example/reference.md @@ -60,6 +60,13 @@ Ignore the deprecated root `examples/` folder for new work. | `typescript` | `templates/typescript/` | `node_modules` | `.env` | `pnpm install --config.confirmModulesPurge=false` | | `python` | `templates/python/` | `.venv` | `.env` | create `.venv`, upgrade `pip`, `pip install -r requirements.txt` | | `nextjs` | `templates/nextjs/` | `node_modules`, `.next` | `.env.local` | patch `package.json`, then `pnpm install --config.confirmModulesPurge=false` | +| `expo` | `templates/expo/` | `node_modules`, `.expo` | `.env` | `pnpm install --config.confirmModulesPurge=false` | + +## Expo runtime notes + +- `templates/expo/` should provide the generic Expo Router app shell, `web.output: "server"` config, a baseline `/api/health` route, and reusable verification scripts such as `typecheck` and `export:web`. +- Keep Expo `PROMPT.md` files focused on ElevenLabs-specific UI, SDK usage, token exchange, `+api.ts` routes, and error handling instead of generic app bootstrapping. +- Until the repo has dedicated Expo examples, use the closest same-product `nextjs` example as the authoring reference for Expo scaffolds. ## Prompt rules @@ -69,11 +76,12 @@ Ignore the deprecated root `examples/` folder for new work. - Keep prompts short and implementation-focused. Current prompts are direct checklists, not essays. - Mention the concrete SDK client, env loading, output format, model ids, voice ids, API route security, and UI behavior when those details are known. - Do not repeat repo-wide context that the generator already injects. +- For `expo`, assume the shared template already includes the app shell and baseline checks; only prompt for ElevenLabs-specific changes. ## README rules - Always include a title, one-sentence summary, `## Setup`, and `## Run`. -- Add `## Usage` for interactive or multi-step examples such as Next.js and agents demos. +- Add `## Usage` for interactive or multi-step examples such as Next.js, Expo, and agents demos. - Keep commands valid from inside `example/`. - Use the closest current example as the formatting reference. @@ -85,6 +93,7 @@ Ignore the deprecated root `examples/` folder for new work. - CLI transcription or file-based Scribe example: start from the speech-to-text quickstarts. - Realtime microphone UI: start from `speech-to-text/nextjs/realtime`. - Voice agent creation and conversation UI: start from `agents/nextjs/quickstart`. +- First Expo full-stack example for a product: start from the closest same-product `nextjs` example until a dedicated Expo reference exists. - For specialized agent behavior, start from `agents/nextjs/quickstart` and consult `agents/nextjs/guardrails` only as an existing reference, not as a scaffold mode. ## Scaffold helper @@ -93,7 +102,7 @@ The helper script creates a new example directory by copying `PROMPT.md`, `READM ```bash python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \ - --path agents/nextjs/my-agent-demo + --path agents/expo/my-agent-demo ``` Useful flags: diff --git a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py index b6f5d12d..99e848d3 100644 --- a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py +++ b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py @@ -18,11 +18,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--path", required=True, - help="Relative path like product/runtime/my-example", + help="Relative path like product/runtime/my-example " + "(for example, text-to-speech/expo/my-example).", ) parser.add_argument( "--reference", - help="Explicit reference example path (e.g. music/nextjs/quickstart). " + help="Explicit reference example path (e.g. speech-to-text/nextjs/realtime). " "Auto-detected from the repo when omitted.", ) parser.add_argument( @@ -44,7 +45,7 @@ def parse_example_path(path_text: str) -> tuple[str, str, str]: if len(parts) != 3: raise SystemExit( "Example paths must look like //, for example " - "text-to-speech/nextjs/my-example." + "text-to-speech/expo/my-example." ) product, runtime, slug = parts @@ -70,23 +71,44 @@ def find_existing_examples() -> list[tuple[str, str, str, Path]]: return results +def pick_reference(paths: list[Path]) -> Path | None: + if not paths: + return None + + return sorted(paths, key=lambda path: (path.name != "quickstart", str(path)))[0] + + def find_reference( product: str, runtime: str, examples: list[tuple[str, str, str, Path]] ) -> Path | None: """Pick the best existing example to copy from. - Priority: same product+runtime > same runtime > first available. + Priority: same product+runtime > same runtime > same product+nextjs for + Expo > any nextjs for Expo > first available. """ same_product_runtime = [d for p, r, _, d in examples if p == product and r == runtime] - if same_product_runtime: - return same_product_runtime[0] + match = pick_reference(same_product_runtime) + if match: + return match same_runtime = [d for _, r, _, d in examples if r == runtime] - if same_runtime: - return same_runtime[0] + match = pick_reference(same_runtime) + if match: + return match + + if runtime == "expo": + same_product_nextjs = [d for p, r, _, d in examples if p == product and r == "nextjs"] + match = pick_reference(same_product_nextjs) + if match: + return match + + nextjs_examples = [d for _, r, _, d in examples if r == "nextjs"] + match = pick_reference(nextjs_examples) + if match: + return match if examples: - return examples[0][3] + return pick_reference([example_dir for _, _, _, example_dir in examples]) return None diff --git a/README.md b/README.md index 1f272f9b..bf248693 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Prompt-driven ElevenLabs examples for text-to-speech, speech-to-text, music, sou - `setup.sh` — scaffolds the `example/` directory from a shared template - `example/` — the generated, runnable example with its own `README.md` -Shared base templates live in `templates/` (Next.js, Python, TypeScript). UI styling rules are in `DESIGN.md`. +Shared base templates live in `templates/` (Expo, Next.js, Python, TypeScript). UI styling rules are in `DESIGN.md`. > The legacy `examples/` folder is being deprecated and can be ignored for new work. diff --git a/agents/expo/quickstart/PROMPT.md b/agents/expo/quickstart/PROMPT.md new file mode 100644 index 00000000..77c456db --- /dev/null +++ b/agents/expo/quickstart/PROMPT.md @@ -0,0 +1,28 @@ +Before writing any code, invoke the `/agents` skill to learn the correct ElevenLabs SDK patterns. + +## `app/api/agent+api.ts` + +Secure Expo Router API route that creates or loads a voice agent. Never expose `ELEVENLABS_API_KEY` to the client. + +- `POST` creates a new voice agent with sensible defaults (name, system prompt, first message, TTS voice). Use the CLI `voice-only` template as reference for the agent shape. +- `GET` loads an existing agent by `agentId` query param. +- Configure as voice-first: real TTS voice and model, text-only disabled, widget text input disabled. +- For English agents (`language: "en"`), use `tts.modelId: "eleven_flash_v2"`. Do not use `eleven_flash_v2_5` for English-only agents, or agent creation may fail validation. +- Enable client events needed for transcript rendering and audio. +- Return `{ agentId, agentName }`. + +## `app/api/conversation-token+api.ts` + +Secure GET endpoint that returns a WebRTC conversation token for a given `agentId` using `getWebrtcToken`. +Never expose `ELEVENLABS_API_KEY` to the client. Return `{ token }` as JSON. + +## `app/index.tsx` + +Minimal Expo Router voice agent screen. + +- Use `@elevenlabs/react` and the `useConversation` hook for the web experience. +- Show a `Create Agent` button and an editable agent-id input. Auto-populate on create; allow pasting a different id to load it instead. +- Start WebRTC sessions with a token from `/api/conversation-token` using `startSession({ conversationToken, connectionType: "webrtc" })`. Request mic access before starting. +- Show a Start/Stop toggle, connection status, and running conversation transcript (append messages, don't replace). +- Handle errors gracefully and allow reconnect. Keep the UI simple and voice-first. +- Keep the verified path web-first: use relative fetch calls for Expo web, and render a brief native fallback note instead of attempting an unsupported in-app server flow. diff --git a/agents/expo/quickstart/README.md b/agents/expo/quickstart/README.md new file mode 100644 index 00000000..9685b942 --- /dev/null +++ b/agents/expo/quickstart/README.md @@ -0,0 +1,39 @@ +# Real-Time Voice Agent (Expo) + +Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web. + +## Setup + +1. Copy the environment file and add your credentials: + + ```bash + cp .env.example .env + ``` + + Then edit `.env` and set: + - `ELEVENLABS_API_KEY` + +2. Install dependencies: + + ```bash + pnpm install + ``` + +## Run + +```bash +pnpm run web +``` + +Open the local Expo web URL shown in the terminal. + +## Usage + +- Enter an agent name and a system prompt, then click **Create agent**. +- The app creates the agent server-side and stores the returned agent id in the page. +- Click **Start** and allow microphone access when prompted. +- The app fetches a fresh conversation token for the created agent and starts a WebRTC session. +- Speak naturally and watch the live conversation state update as the agent listens and responds. +- The page shows whether the agent is currently speaking and renders the interaction as a running conversation. +- Click **Stop** to end the session. +- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes. diff --git a/agents/expo/quickstart/example/.env.example b/agents/expo/quickstart/example/.env.example new file mode 100644 index 00000000..4c49a949 --- /dev/null +++ b/agents/expo/quickstart/example/.env.example @@ -0,0 +1 @@ +ELEVENLABS_API_KEY= diff --git a/agents/expo/quickstart/example/.gitignore b/agents/expo/quickstart/example/.gitignore new file mode 100644 index 00000000..938122f1 --- /dev/null +++ b/agents/expo/quickstart/example/.gitignore @@ -0,0 +1,7 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +.expo/ +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/agents/expo/quickstart/example/README.md b/agents/expo/quickstart/example/README.md new file mode 100644 index 00000000..9685b942 --- /dev/null +++ b/agents/expo/quickstart/example/README.md @@ -0,0 +1,39 @@ +# Real-Time Voice Agent (Expo) + +Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web. + +## Setup + +1. Copy the environment file and add your credentials: + + ```bash + cp .env.example .env + ``` + + Then edit `.env` and set: + - `ELEVENLABS_API_KEY` + +2. Install dependencies: + + ```bash + pnpm install + ``` + +## Run + +```bash +pnpm run web +``` + +Open the local Expo web URL shown in the terminal. + +## Usage + +- Enter an agent name and a system prompt, then click **Create agent**. +- The app creates the agent server-side and stores the returned agent id in the page. +- Click **Start** and allow microphone access when prompted. +- The app fetches a fresh conversation token for the created agent and starts a WebRTC session. +- Speak naturally and watch the live conversation state update as the agent listens and responds. +- The page shows whether the agent is currently speaking and renders the interaction as a running conversation. +- Click **Stop** to end the session. +- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes. diff --git a/agents/expo/quickstart/example/app.json b/agents/expo/quickstart/example/app.json new file mode 100644 index 00000000..a83296d6 --- /dev/null +++ b/agents/expo/quickstart/example/app.json @@ -0,0 +1,16 @@ +{ + "expo": { + "name": "Real-Time Voice Agent", + "slug": "realtime-voice-agent-expo", + "scheme": "realtime-voice-agent-expo", + "version": "1.0.0", + "orientation": "portrait", + "web": { + "output": "server" + }, + "plugins": ["expo-router"], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/agents/expo/quickstart/example/app/_layout.tsx b/agents/expo/quickstart/example/app/_layout.tsx new file mode 100644 index 00000000..2ab324f2 --- /dev/null +++ b/agents/expo/quickstart/example/app/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; + +export default function RootLayout() { + return ( + + + + + ); +} diff --git a/agents/expo/quickstart/example/app/api/agent+api.ts b/agents/expo/quickstart/example/app/api/agent+api.ts new file mode 100644 index 00000000..513635f5 --- /dev/null +++ b/agents/expo/quickstart/example/app/api/agent+api.ts @@ -0,0 +1,103 @@ +import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; +import { ClientEvent } from "@elevenlabs/elevenlabs-js/api/types/ClientEvent"; +import type { ConversationalConfig } from "@elevenlabs/elevenlabs-js/api/types/ConversationalConfig"; + +function getClient() { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { + return { + error: Response.json( + { error: "Missing ELEVENLABS_API_KEY" }, + { status: 500 } + ), + }; + } + return { client: new ElevenLabsClient({ apiKey }) }; +} + +function voiceFirstConversationConfig(): ConversationalConfig { + return { + agent: { + firstMessage: "Hello! How can I help you today?", + language: "en", + prompt: { + prompt: + "You are a helpful voice assistant. Keep replies concise and natural for spoken conversation.", + llm: "gemini-2.0-flash", + temperature: 0.7, + }, + }, + tts: { + voiceId: "JBFqnCBsd6RMkjVDRZzb", + modelId: "eleven_flash_v2", + }, + conversation: { + textOnly: false, + clientEvents: [ + ClientEvent.UserTranscript, + ClientEvent.TentativeUserTranscript, + ClientEvent.AgentResponse, + ClientEvent.AgentChatResponsePart, + ClientEvent.Audio, + ], + }, + }; +} + +export async function POST() { + const res = getClient(); + if ("error" in res) { + return res.error; + } + + try { + const agentName = "Expo Voice Agent"; + const created = await res.client.conversationalAi.agents.create({ + name: agentName, + enableVersioning: true, + conversationConfig: voiceFirstConversationConfig(), + platformSettings: { + widget: { + textInputEnabled: false, + supportsTextOnly: false, + conversationModeToggleEnabled: false, + }, + }, + }); + + return Response.json({ + agentId: created.agentId, + agentName, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to create agent"; + return Response.json({ error: message }, { status: 500 }); + } +} + +export async function GET(request: Request) { + const res = getClient(); + if ("error" in res) { + return res.error; + } + + const url = new URL(request.url); + const agentId = url.searchParams.get("agentId"); + if (!agentId?.trim()) { + return Response.json( + { error: "Missing agentId query parameter" }, + { status: 400 } + ); + } + + try { + const agent = await res.client.conversationalAi.agents.get(agentId.trim()); + return Response.json({ + agentId: agent.agentId, + agentName: agent.name, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to load agent"; + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/agents/expo/quickstart/example/app/api/conversation-token+api.ts b/agents/expo/quickstart/example/app/api/conversation-token+api.ts new file mode 100644 index 00000000..fc195685 --- /dev/null +++ b/agents/expo/quickstart/example/app/api/conversation-token+api.ts @@ -0,0 +1,31 @@ +import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; + +export async function GET(request: Request) { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { + return Response.json( + { error: "Missing ELEVENLABS_API_KEY" }, + { status: 500 } + ); + } + + const url = new URL(request.url); + const agentId = url.searchParams.get("agentId"); + if (!agentId?.trim()) { + return Response.json( + { error: "Missing agentId query parameter" }, + { status: 400 } + ); + } + + try { + const client = new ElevenLabsClient({ apiKey }); + const result = await client.conversationalAi.conversations.getWebrtcToken({ + agentId: agentId.trim(), + }); + return Response.json({ token: result.token }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to get token"; + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/agents/expo/quickstart/example/app/api/health+api.ts b/agents/expo/quickstart/example/app/api/health+api.ts new file mode 100644 index 00000000..d4b7bc80 --- /dev/null +++ b/agents/expo/quickstart/example/app/api/health+api.ts @@ -0,0 +1,6 @@ +export function GET() { + return Response.json({ + ok: true, + runtime: "expo-router-api", + }); +} diff --git a/agents/expo/quickstart/example/app/index.tsx b/agents/expo/quickstart/example/app/index.tsx new file mode 100644 index 00000000..b95bac52 --- /dev/null +++ b/agents/expo/quickstart/example/app/index.tsx @@ -0,0 +1,441 @@ +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { ConversationProvider, useConversation } from "@elevenlabs/react"; + +type TranscriptLine = { + key: string; + role: "user" | "agent"; + text: string; +}; + +function VoiceAgentPanel() { + const [agentId, setAgentId] = useState(""); + const [agentName, setAgentName] = useState(null); + const [transcript, setTranscript] = useState([]); + const [apiError, setApiError] = useState(null); + const [busy, setBusy] = useState<"idle" | "create" | "load" | "token">( + "idle" + ); + + const { + startSession, + endSession, + status, + message: statusMessage, + } = useConversation({ + onMessage: props => { + setTranscript(prev => [ + ...prev, + { + key: `${props.event_id ?? Date.now()}-${prev.length}`, + role: props.role, + text: props.message, + }, + ]); + }, + }); + + const clearConversationError = useCallback(() => { + setApiError(null); + }, []); + + const createAgent = useCallback(async () => { + setBusy("create"); + setApiError(null); + try { + const response = await fetch("/api/agent", { method: "POST" }); + const data = (await response.json()) as { + agentId?: string; + agentName?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(data.error ?? `Request failed (${response.status})`); + } + if (!data.agentId) { + throw new Error("Missing agentId in response"); + } + setAgentId(data.agentId); + setAgentName(data.agentName ?? null); + } catch (e) { + setApiError(e instanceof Error ? e.message : "Failed to create agent"); + } finally { + setBusy("idle"); + } + }, []); + + const loadAgent = useCallback(async () => { + if (!agentId.trim()) { + setApiError("Enter an agent id first."); + return; + } + setBusy("load"); + setApiError(null); + try { + const response = await fetch( + `/api/agent?agentId=${encodeURIComponent(agentId.trim())}` + ); + const data = (await response.json()) as { + agentId?: string; + agentName?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(data.error ?? `Request failed (${response.status})`); + } + setAgentName(data.agentName ?? null); + } catch (e) { + setApiError(e instanceof Error ? e.message : "Failed to load agent"); + } finally { + setBusy("idle"); + } + }, [agentId]); + + const startVoice = useCallback(async () => { + if (!agentId.trim()) { + setApiError("Enter or create an agent id first."); + return; + } + setBusy("token"); + setApiError(null); + setTranscript([]); + try { + if ( + typeof navigator !== "undefined" && + navigator.mediaDevices?.getUserMedia + ) { + await navigator.mediaDevices.getUserMedia({ audio: true }); + } + const response = await fetch( + `/api/conversation-token?agentId=${encodeURIComponent(agentId.trim())}` + ); + const data = (await response.json()) as { + token?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(data.error ?? `Request failed (${response.status})`); + } + if (!data.token) { + throw new Error("Missing token in response"); + } + await startSession({ + conversationToken: data.token, + connectionType: "webrtc", + }); + } catch (e) { + setApiError(e instanceof Error ? e.message : "Failed to start session"); + } finally { + setBusy("idle"); + } + }, [agentId, startSession]); + + const stopVoice = useCallback(() => { + setApiError(null); + void endSession(); + }, [endSession]); + + const isBusy = busy !== "idle"; + const isConnected = status === "connected"; + const canStart = !isBusy && !isConnected && !!agentId.trim(); + const primaryDisabled = isConnected ? false : !canStart || isBusy; + + return ( + + Agent ID + { + setAgentId(t); + clearConversationError(); + }} + placeholder="Paste or create an agent id" + style={styles.input} + value={agentId} + /> + + + [ + styles.buttonSecondary, + (pressed || isBusy) && styles.buttonPressed, + ]} + > + {busy === "create" ? ( + + ) : ( + Create Agent + )} + + [ + styles.buttonSecondary, + (pressed || isBusy) && styles.buttonPressed, + ]} + > + {busy === "load" ? ( + + ) : ( + Load agent + )} + + + + {agentName ? Loaded: {agentName} : null} + + [ + styles.buttonPrimary, + (pressed || (isBusy && busy === "token")) && styles.buttonPressed, + primaryDisabled ? styles.buttonDisabled : null, + ]} + > + {busy === "token" ? ( + + ) : ( + + {isConnected ? "Stop" : "Start"} + + )} + + + Status + + {status} + {statusMessage ? ` — ${statusMessage}` : ""} + + + {apiError ? {apiError} : null} + + Transcript + + {transcript.length === 0 ? ( + + Messages appear here during a conversation. + + ) : ( + transcript.map(line => ( + + + {line.role === "user" ? "You" : "Agent"}:{" "} + + {line.text} + + )) + )} + + + ); +} + +export default function HomeScreen() { + const [providerError, setProviderError] = useState(null); + + if (Platform.OS !== "web") { + return ( + + + Expo Template + Voice agent (web) + + This example uses Expo Router API routes and the ElevenLabs web + conversation client. Run the app with{" "} + npx expo start --web to + try voice on the web build. + + + + ); + } + + return ( + + { + console.error("Conversation error:", msg); + setProviderError(msg); + }} + > + + Expo Template + Voice agent + + Create a voice agent, then start a session. Mic access is requested + when you start. The transcript appends each message. + + {providerError ? ( + {providerError} + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#ffffff", + }, + container: { + flex: 1, + width: "100%", + maxWidth: 480, + alignSelf: "center", + paddingHorizontal: 24, + paddingVertical: 48, + }, + eyebrow: { + color: "#525252", + fontSize: 13, + fontWeight: "600", + letterSpacing: 0.5, + textTransform: "uppercase", + }, + title: { + marginTop: 12, + color: "#171717", + fontSize: 28, + fontWeight: "600", + letterSpacing: -0.5, + }, + description: { + marginTop: 8, + color: "#737373", + fontSize: 15, + lineHeight: 22, + }, + descriptionEm: { + fontWeight: "600", + color: "#525252", + }, + label: { + marginTop: 24, + fontSize: 12, + color: "#a3a3a3", + }, + input: { + marginTop: 6, + borderWidth: 1, + borderColor: "#e5e5e5", + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: "#171717", + backgroundColor: "#ffffff", + }, + row: { + flexDirection: "row", + gap: 8, + marginTop: 12, + }, + buttonPrimary: { + marginTop: 16, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: 6, + backgroundColor: "#171717", + paddingHorizontal: 16, + paddingVertical: 10, + }, + buttonSecondary: { + flex: 1, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: 6, + borderWidth: 1, + borderColor: "#e5e5e5", + backgroundColor: "#ffffff", + paddingHorizontal: 12, + paddingVertical: 10, + }, + buttonSecondaryText: { + fontSize: 14, + fontWeight: "500", + color: "#171717", + }, + buttonPressed: { + opacity: 0.85, + }, + buttonDisabled: { + opacity: 0.45, + }, + buttonText: { + color: "#ffffff", + fontSize: 14, + fontWeight: "600", + }, + meta: { + marginTop: 8, + fontSize: 13, + color: "#525252", + }, + statusLabel: { + marginTop: 20, + fontSize: 12, + color: "#a3a3a3", + }, + status: { + marginTop: 4, + fontSize: 14, + color: "#404040", + lineHeight: 20, + }, + transcriptLabel: { + marginTop: 20, + fontSize: 12, + color: "#a3a3a3", + }, + transcriptBox: { + marginTop: 8, + maxHeight: 320, + borderWidth: 1, + borderColor: "#e5e5e5", + borderRadius: 6, + padding: 12, + }, + transcriptEmpty: { + fontSize: 14, + color: "#a3a3a3", + }, + transcriptLine: { + fontSize: 14, + color: "#404040", + lineHeight: 20, + marginBottom: 8, + }, + transcriptRole: { + fontWeight: "600", + color: "#171717", + }, + error: { + marginTop: 12, + fontSize: 14, + color: "#b91c1c", + lineHeight: 20, + }, +}); diff --git a/agents/expo/quickstart/example/babel.config.js b/agents/expo/quickstart/example/babel.config.js new file mode 100644 index 00000000..3d7f2266 --- /dev/null +++ b/agents/expo/quickstart/example/babel.config.js @@ -0,0 +1,18 @@ +module.exports = function (api) { + api.cache(true); + + return { + presets: ["babel-preset-expo"], + plugins: [ + [ + "module-resolver", + { + root: ["."], + alias: { + "@": ".", + }, + }, + ], + ], + }; +}; diff --git a/agents/expo/quickstart/example/package.json b/agents/expo/quickstart/example/package.json new file mode 100644 index 00000000..92f9ba85 --- /dev/null +++ b/agents/expo/quickstart/example/package.json @@ -0,0 +1,41 @@ +{ + "name": "realtime-voice-agent-expo", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "export:web": "expo export --platform web", + "serve:web": "expo serve" + }, + "dependencies": { + "@expo/metro-runtime": "latest", + "expo": "latest", + "expo-constants": "latest", + "expo-linking": "latest", + "expo-router": "latest", + "expo-status-bar": "latest", + "react": "latest", + "react-dom": "latest", + "react-native": "latest", + "react-native-safe-area-context": "latest", + "react-native-screens": "latest", + "react-native-web": "latest", + "@elevenlabs/react": "^1.1.1", + "@elevenlabs/elevenlabs-js": "^2.43.0" + }, + "devDependencies": { + "@types/react": "latest", + "babel-plugin-module-resolver": "latest", + "typescript": "latest" + }, + "pnpm": { + "overrides": { + "livekit-client": "2.16.1" + } + } +} diff --git a/agents/expo/quickstart/example/tsconfig.json b/agents/expo/quickstart/example/tsconfig.json new file mode 100644 index 00000000..320a8c20 --- /dev/null +++ b/agents/expo/quickstart/example/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} diff --git a/agents/expo/quickstart/setup.sh b/agents/expo/quickstart/setup.sh new file mode 100755 index 00000000..f4ee2a01 --- /dev/null +++ b/agents/expo/quickstart/setup.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$DIR/../../.." && pwd)" +cd "$DIR" + +# Clean example/ but preserve cached installs and Expo state for speed +if [ -d example ]; then + find example -mindepth 1 -maxdepth 1 ! -name node_modules ! -name .expo -exec rm -rf {} + +fi +mkdir -p example + +# Copy shared Expo template structure +rsync -a \ + --exclude node_modules --exclude .expo \ + --exclude pnpm-lock.yaml --exclude package-lock.json \ + --exclude example \ + "$REPO_ROOT/templates/expo/" example/ + +# Copy project-specific README +cp README.md example/README.md + +# Add ElevenLabs dependencies (fetch latest versions at setup time) +cd example +export REACT_VER=$(npm view @elevenlabs/react version) +export ELEVENLABS_VER=$(npm view @elevenlabs/elevenlabs-js version) +node -e " + const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); + const app = JSON.parse(require('fs').readFileSync('app.json', 'utf8')); + pkg.name = 'realtime-voice-agent-expo'; + pkg.dependencies['@elevenlabs/react'] = '^' + process.env.REACT_VER; + pkg.dependencies['@elevenlabs/elevenlabs-js'] = '^' + process.env.ELEVENLABS_VER; + pkg.pnpm = pkg.pnpm || {}; + pkg.pnpm.overrides = pkg.pnpm.overrides || {}; + pkg.pnpm.overrides['livekit-client'] = '2.16.1'; + require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + app.expo.name = 'Real-Time Voice Agent'; + app.expo.slug = 'realtime-voice-agent-expo'; + app.expo.scheme = 'realtime-voice-agent-expo'; + require('fs').writeFileSync('app.json', JSON.stringify(app, null, 2) + '\n'); +" + +# Create API route directory +mkdir -p app/api + +# Setup env +if [ -f "$DIR/.env" ]; then + cp "$DIR/.env" .env +fi + +# Install dependencies +pnpm install --config.confirmModulesPurge=false diff --git a/agents/nextjs/quickstart/example/app/page.tsx b/agents/nextjs/quickstart/example/app/page.tsx index 163b5ea4..84494f65 100644 --- a/agents/nextjs/quickstart/example/app/page.tsx +++ b/agents/nextjs/quickstart/example/app/page.tsx @@ -107,21 +107,27 @@ function VoiceAgentPage({ const canStart = trimmedId.length > 0 && !starting; const sessionActive = status === "connected" || status === "connecting"; - const statusLabel = - status === "connected" - ? "Connected" - : status === "connecting" - ? "Connecting…" - : status === "error" - ? (message ?? "Connection error") - : "Disconnected"; + const statusLabel = (() => { + switch (status) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting…"; + case "disconnected": + return "Disconnected"; + case "error": + return message?.trim() ? `Error: ${message}` : "Error"; + default: { + const exhaustiveStatus: never = status; + return exhaustiveStatus; + } + } + })(); function handleAgentIdChange(value: string) { setAgentIdInput(value); - if (!value.trim()) { - setAgentLookupOk(false); - setAgentLookupError(null); - } + setAgentLookupError(null); + setAgentLookupOk(false); } async function handleCreateAgent() { diff --git a/templates/expo/.env.example b/templates/expo/.env.example new file mode 100644 index 00000000..4c49a949 --- /dev/null +++ b/templates/expo/.env.example @@ -0,0 +1 @@ +ELEVENLABS_API_KEY= diff --git a/templates/expo/app.json b/templates/expo/app.json new file mode 100644 index 00000000..2b9c4fec --- /dev/null +++ b/templates/expo/app.json @@ -0,0 +1,16 @@ +{ + "expo": { + "name": "template", + "slug": "template", + "scheme": "template", + "version": "1.0.0", + "orientation": "portrait", + "web": { + "output": "server" + }, + "plugins": ["expo-router"], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/templates/expo/app/_layout.tsx b/templates/expo/app/_layout.tsx new file mode 100644 index 00000000..2ab324f2 --- /dev/null +++ b/templates/expo/app/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; + +export default function RootLayout() { + return ( + + + + + ); +} diff --git a/templates/expo/app/api/health+api.ts b/templates/expo/app/api/health+api.ts new file mode 100644 index 00000000..d4b7bc80 --- /dev/null +++ b/templates/expo/app/api/health+api.ts @@ -0,0 +1,6 @@ +export function GET() { + return Response.json({ + ok: true, + runtime: "expo-router-api", + }); +} diff --git a/templates/expo/app/index.tsx b/templates/expo/app/index.tsx new file mode 100644 index 00000000..570fe6fc --- /dev/null +++ b/templates/expo/app/index.tsx @@ -0,0 +1,137 @@ +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +type HealthResponse = { + ok: boolean; + runtime: string; +}; + +export default function HomeScreen() { + const [status, setStatus] = useState( + "Tap below to confirm the server route." + ); + const [loading, setLoading] = useState(false); + + const checkHealthRoute = useCallback(async () => { + setLoading(true); + setStatus("Checking /api/health..."); + + try { + const response = await fetch("/api/health"); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = (await response.json()) as HealthResponse; + setStatus( + data.ok + ? `Server route ready (${data.runtime}).` + : "Server route returned an unexpected response." + ); + } catch (error) { + setStatus( + error instanceof Error + ? error.message + : "Unable to reach the server route." + ); + } finally { + setLoading(false); + } + }, []); + + return ( + + + Expo Template + Server-ready app shell + + Use this shared Expo Router template for ElevenLabs examples. Keep the + base scaffold generic here, and put product-specific UI and API logic + in each example prompt. + + [ + styles.button, + (pressed || loading) && styles.buttonPressed, + ]} + > + {loading ? ( + + ) : ( + Check /api/health + )} + + {status} + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#ffffff", + }, + container: { + flex: 1, + width: "100%", + maxWidth: 480, + alignSelf: "center", + paddingHorizontal: 24, + paddingVertical: 48, + }, + eyebrow: { + color: "#525252", + fontSize: 13, + fontWeight: "600", + letterSpacing: 0.5, + textTransform: "uppercase", + }, + title: { + marginTop: 12, + color: "#171717", + fontSize: 28, + fontWeight: "600", + letterSpacing: -0.5, + }, + description: { + marginTop: 8, + color: "#737373", + fontSize: 15, + lineHeight: 22, + }, + button: { + marginTop: 24, + minHeight: 48, + alignItems: "center", + justifyContent: "center", + borderRadius: 999, + backgroundColor: "#171717", + paddingHorizontal: 18, + }, + buttonPressed: { + opacity: 0.8, + }, + buttonText: { + color: "#ffffff", + fontSize: 15, + fontWeight: "600", + }, + status: { + marginTop: 16, + color: "#404040", + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/templates/expo/babel.config.js b/templates/expo/babel.config.js new file mode 100644 index 00000000..3d7f2266 --- /dev/null +++ b/templates/expo/babel.config.js @@ -0,0 +1,18 @@ +module.exports = function (api) { + api.cache(true); + + return { + presets: ["babel-preset-expo"], + plugins: [ + [ + "module-resolver", + { + root: ["."], + alias: { + "@": ".", + }, + }, + ], + ], + }; +}; diff --git a/templates/expo/expo-env.d.ts b/templates/expo/expo-env.d.ts new file mode 100644 index 00000000..e6394b24 --- /dev/null +++ b/templates/expo/expo-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/templates/expo/package.json b/templates/expo/package.json new file mode 100644 index 00000000..b633251c --- /dev/null +++ b/templates/expo/package.json @@ -0,0 +1,34 @@ +{ + "name": "template", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "export:web": "expo export --platform web", + "serve:web": "expo serve" + }, + "dependencies": { + "@expo/metro-runtime": "latest", + "expo": "latest", + "expo-constants": "latest", + "expo-linking": "latest", + "expo-router": "latest", + "expo-status-bar": "latest", + "react": "latest", + "react-dom": "latest", + "react-native": "latest", + "react-native-safe-area-context": "latest", + "react-native-screens": "latest", + "react-native-web": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "babel-plugin-module-resolver": "latest", + "typescript": "latest" + } +} diff --git a/templates/expo/tsconfig.json b/templates/expo/tsconfig.json new file mode 100644 index 00000000..320a8c20 --- /dev/null +++ b/templates/expo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +}