+
+
+
+
+
+
Getting Started
+
Installation
+
+
+
Start the Reachy Mini simulator and browser app locally:
+
cp .env.example .env
+export OPENAI_API_KEY=sk-...
+./scripts/start-local.sh
+
+
+
+
+
+
Configuration
+
Provider Configuration
+
+
+
The default .env.example uses OpenAI Realtime. Configure provider credentials and model routing in .env, then choose microphone or text input in the app UI.
+
+
+
+
diff --git a/projects/reachy-mini-openshell/pyproject.toml b/projects/reachy-mini-openshell/pyproject.toml
new file mode 100644
index 00000000..ce47a1b0
--- /dev/null
+++ b/projects/reachy-mini-openshell/pyproject.toml
@@ -0,0 +1,77 @@
+[build-system]
+requires = [ "setuptools",]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "reachy_mini_conversation_app"
+version = "0.3.0"
+description = "Reachy Mini conversation demo for OpenShell."
+readme = "README.md"
+requires-python = ">=3.10,<3.13"
+dependencies = [ "aiortc>=1.13.0", "fastrtc>=0.0.34", "gradio==5.50.1.dev1", "huggingface-hub==1.3.0", "opencv-python>=4.12.0.88", "python-dotenv", "openai>=2.1", "reachy_mini_dances_library", "reachy_mini_toolbox", "reachy-mini[mujoco]==1.8.0", "eclipse-zenoh~=1.7.0", "gradio_client>=1.13.3",]
+[[project.authors]]
+name = "Pollen Robotics"
+email = "contact@pollen-robotics.com"
+
+[dependency-groups]
+dev = [
+ "pytest",
+ "pytest-asyncio",
+ "ruff==0.12.0",
+ "pre-commit",
+ "types-requests",
+ "python-semantic-release>=10.5.3",
+ "ty>=0.0.53",
+]
+
+[tool.uv]
+environments = [
+ "sys_platform == 'darwin'",
+]
+
+[project.optional-dependencies]
+local_vision = [ "torch>=2.1", "transformers==5.0.0rc2", "num2words",]
+yolo_vision = [ "ultralytics", "supervision", "onnxruntime",]
+mediapipe_vision = [ "mediapipe==0.10.14",]
+all_vision = [ "torch>=2.1", "transformers==5.0.0rc2", "num2words", "ultralytics", "supervision", "onnxruntime", "mediapipe==0.10.14",]
+
+[project.scripts]
+reachy-mini-conversation-app = "reachy_mini_conversation_app.main:main"
+reachy-mini-backend-check = "reachy_mini_conversation_app.backend_check:main"
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.ruff]
+line-length = 119
+exclude = [ ".venv", "dist", "build", "**/__pycache__", "*.egg-info", ".pytest_cache",]
+
+[project.entry-points.reachy_mini_apps]
+reachy_mini_conversation_app = "reachy_mini_conversation_app.main:ReachyMiniConversationApp"
+
+[tool.setuptools.package-dir]
+"" = "src"
+
+[tool.setuptools.package-data]
+reachy_mini_conversation_app = [ "images/*", "profiles/**/*.py", "profiles/**/*.txt",]
+
+[tool.ruff.lint]
+select = [ "E", "F", "W", "I", "C4", "D",]
+ignore = [ "E501", "D100", "D203", "D213",]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+
+[tool.setuptools.packages.find]
+where = [ "src",]
+
+[tool.ruff.lint.isort]
+length-sort = true
+lines-after-imports = 2
+no-lines-before = [ "standard-library", "local-folder",]
+known-local-folder = [ "reachy_mini_conversation_app",]
+known-first-party = [ "reachy_mini", "reachy_mini_dances_library", "reachy_mini_toolbox",]
+split-on-trailing-comma = true
diff --git a/projects/reachy-mini-openshell/scripts/fake_openai_backend.py b/projects/reachy-mini-openshell/scripts/fake_openai_backend.py
new file mode 100755
index 00000000..54f5f459
--- /dev/null
+++ b/projects/reachy-mini-openshell/scripts/fake_openai_backend.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Tiny OpenAI-compatible backend for local Reachy conversation smoke tests."""
+
+import io
+import math
+import time
+import wave
+import argparse
+from typing import Any
+
+import uvicorn
+from fastapi import FastAPI, Request, Response
+
+
+def _wav_bytes(text: str, *, sample_rate: int = 24_000, duration_seconds: float = 0.7) -> bytes:
+ """Return a small mono WAV payload."""
+ frequency = 523.25 if "swept" in text.lower() else 440.0
+ frame_count = max(1, int(sample_rate * duration_seconds))
+ frames = bytearray()
+ for index in range(frame_count):
+ sample = int(math.sin(2 * math.pi * frequency * index / sample_rate) * 6000)
+ frames.extend(sample.to_bytes(2, byteorder="little", signed=True))
+
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(sample_rate)
+ wav_file.writeframes(bytes(frames))
+ return buffer.getvalue()
+
+
+def _message_has_tool_result(messages: list[Any]) -> bool:
+ """Return whether a Chat Completions request includes a tool result."""
+ return any(isinstance(message, dict) and message.get("role") == "tool" for message in messages)
+
+
+def build_app(
+ *,
+ transcript: str,
+ assistant_text: str,
+ tool_name: str,
+ call_tool: bool,
+ audio_duration_seconds: float,
+) -> FastAPI:
+ """Build the fake OpenAI-compatible FastAPI app."""
+ app = FastAPI(title="Reachy fake OpenAI-compatible backend")
+
+ @app.get("/v1/models")
+ async def models() -> dict[str, Any]:
+ return {
+ "object": "list",
+ "data": [
+ {"id": "fake-whisper", "object": "model"},
+ {"id": "fake-chat", "object": "model"},
+ {"id": "fake-tts", "object": "model"},
+ ],
+ }
+
+ @app.post("/v1/audio/transcriptions")
+ async def transcriptions(request: Request) -> dict[str, str]:
+ await request.body()
+ return {"text": transcript}
+
+ @app.post("/v1/audio/speech")
+ async def speech(request: Request) -> Response:
+ try:
+ payload = await request.json()
+ except Exception:
+ payload = {}
+ input_text = str(payload.get("input", "hello"))
+ return Response(
+ content=_wav_bytes(input_text, duration_seconds=audio_duration_seconds),
+ media_type="audio/wav",
+ )
+
+ @app.post("/v1/chat/completions")
+ async def chat_completions(request: Request) -> dict[str, Any]:
+ payload = await request.json()
+ messages = payload.get("messages", [])
+ if not isinstance(messages, list):
+ messages = []
+
+ if call_tool and not _message_has_tool_result(messages):
+ message = {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "call_fake_reachy_tool",
+ "type": "function",
+ "function": {"name": tool_name, "arguments": "{}"},
+ }
+ ],
+ }
+ finish_reason = "tool_calls"
+ else:
+ message = {"role": "assistant", "content": assistant_text}
+ finish_reason = "stop"
+
+ return {
+ "id": "chatcmpl-fake-reachy",
+ "object": "chat.completion",
+ "created": int(time.time()),
+ "model": payload.get("model", "fake-chat"),
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
+ }
+
+ return app
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Build CLI parser."""
+ parser = argparse.ArgumentParser(description="Run a fake OpenAI-compatible backend for Reachy smoke tests.")
+ parser.add_argument("--host", default="127.0.0.1", help="host to bind.")
+ parser.add_argument("--port", type=int, default=8766, help="port to bind.")
+ parser.add_argument(
+ "--transcript",
+ default="Reachy, use the sweep_look tool, then tell me what you did.",
+ help="transcription text returned by POST /v1/audio/transcriptions.",
+ )
+ parser.add_argument(
+ "--assistant-text",
+ default="I swept my gaze and returned to center.",
+ help="final assistant text returned after a tool result.",
+ )
+ parser.add_argument("--tool-name", default="sweep_look", help="tool name to request from Chat Completions.")
+ parser.add_argument(
+ "--no-tool", action="store_true", help="return assistant text immediately without a tool call."
+ )
+ parser.add_argument("--audio-duration", type=float, default=0.7, help="duration in seconds for fake TTS WAVs.")
+ return parser
+
+
+def main() -> None:
+ """Run the fake backend."""
+ args = build_parser().parse_args()
+ app = build_app(
+ transcript=args.transcript,
+ assistant_text=args.assistant_text,
+ tool_name=args.tool_name,
+ call_tool=not args.no_tool,
+ audio_duration_seconds=args.audio_duration,
+ )
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/projects/reachy-mini-openshell/scripts/smoke-local-stt.sh b/projects/reachy-mini-openshell/scripts/smoke-local-stt.sh
new file mode 100755
index 00000000..cf278910
--- /dev/null
+++ b/projects/reachy-mini-openshell/scripts/smoke-local-stt.sh
@@ -0,0 +1,300 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+HOST="${SMOKE_BACKEND_HOST:-127.0.0.1}"
+PORT="${SMOKE_BACKEND_PORT:-8766}"
+REAL_REACHY="${SMOKE_REAL_REACHY:-0}"
+GRADIO_SMOKE="${SMOKE_GRADIO:-0}"
+APP_HOST="${SMOKE_APP_HOST:-127.0.0.1}"
+APP_PORT="${SMOKE_APP_PORT:-7897}"
+ROBOT_NAME="${SMOKE_ROBOT_NAME:-}"
+SEED_TEXT="${SMOKE_SEED_TEXT:-Reachy, use the sweep_look tool, then tell me what you did.}"
+STARTUP_TIMEOUT="${SMOKE_BACKEND_TIMEOUT:-10}"
+APP_STARTUP_TIMEOUT="${SMOKE_APP_TIMEOUT:-20}"
+SHUTDOWN_TIMEOUT="${SMOKE_SHUTDOWN_TIMEOUT:-25}"
+
+SERVER_PID=""
+APP_PID=""
+SMOKE_ENV=""
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/smoke-local-stt.sh [--real-reachy] [--gradio] [--robot-name NAME]
+
+Runs a local OpenAI-compatible fake STT/chat/TTS backend, writes a temporary
+local_stt config, and checks the app's microphone app-flow path.
+
+Options:
+ --real-reachy Also run the app-flow check through the real Reachy SDK
+ and movement manager. Requires a running daemon/simulator.
+ --gradio Also launch the Gradio app, verify the UI labels, and send
+ a text tool-call prompt through the running app. Requires
+ a running daemon/simulator.
+ --robot-name NAME Robot name to pass to reachy-mini-backend-check with
+ --real-reachy and to the Gradio app smoke when provided.
+ -h, --help Show this help.
+
+Environment:
+ SMOKE_BACKEND_HOST Fake backend host. Default: 127.0.0.1
+ SMOKE_BACKEND_PORT Fake backend port. Default: 8766
+ SMOKE_APP_HOST Gradio app host for --gradio. Default: 127.0.0.1
+ SMOKE_APP_PORT Gradio app port for --gradio. Default: 7897
+ SMOKE_SEED_TEXT Seed text for the generated input speech.
+ SMOKE_BACKEND_TIMEOUT Seconds to wait for the fake backend. Default: 10
+ SMOKE_APP_TIMEOUT Seconds to wait for the Gradio app. Default: 20
+ SMOKE_SHUTDOWN_TIMEOUT Seconds to wait before forcing shutdown. Default: 25
+EOF
+}
+
+log() {
+ printf '[reachy-smoke] %s\n' "$*"
+}
+
+terminate_process_tree() {
+ local pid="$1"
+ local signal="$2"
+ local children child
+
+ children="$(pgrep -P "${pid}" 2>/dev/null || true)"
+ for child in ${children}; do
+ terminate_process_tree "${child}" "${signal}"
+ done
+ kill "-${signal}" "${pid}" >/dev/null 2>&1 || true
+}
+
+wait_for_process_exit() {
+ local pid="$1"
+ local timeout_seconds="$2"
+ local deadline=$((SECONDS + timeout_seconds))
+
+ while kill -0 "${pid}" >/dev/null 2>&1; do
+ if (( SECONDS >= deadline )); then
+ return 1
+ fi
+ sleep 0.2
+ done
+ return 0
+}
+
+stop_process_tree() {
+ local pid="$1"
+ local label="$2"
+ if [[ -z "${pid}" ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then
+ return
+ fi
+
+ terminate_process_tree "${pid}" INT
+ if ! wait_for_process_exit "${pid}" "${SHUTDOWN_TIMEOUT}"; then
+ log "Forcing ${label} shutdown"
+ terminate_process_tree "${pid}" TERM
+ if ! wait_for_process_exit "${pid}" 3; then
+ terminate_process_tree "${pid}" KILL
+ fi
+ fi
+ wait "${pid}" >/dev/null 2>&1 || true
+}
+
+cleanup() {
+ stop_process_tree "${APP_PID}" "Gradio app"
+ stop_process_tree "${SERVER_PID}" "fake backend"
+ if [[ -n "${SMOKE_ENV}" ]]; then
+ rm -f "${SMOKE_ENV}"
+ fi
+}
+trap cleanup EXIT INT TERM
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --real-reachy)
+ REAL_REACHY=1
+ shift
+ ;;
+ --gradio)
+ GRADIO_SMOKE=1
+ shift
+ ;;
+ --robot-name)
+ if [[ $# -lt 2 ]]; then
+ printf 'Missing value for --robot-name\n' >&2
+ exit 2
+ fi
+ ROBOT_NAME="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ printf 'Unknown argument: %s\n' "$1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+cd "${PROJECT_DIR}"
+
+if ! command -v uv >/dev/null 2>&1; then
+ printf 'Missing required command: uv\n' >&2
+ exit 1
+fi
+
+ensure_url_ready() {
+ local url="$1"
+ local timeout_seconds="$2"
+ local label="$3"
+
+ local deadline=$((SECONDS + timeout_seconds))
+ until uv run python - "${url}" <<'PY' >/dev/null 2>&1
+import sys
+import urllib.request
+
+url = sys.argv[1]
+try:
+ with urllib.request.urlopen(url, timeout=0.5) as response:
+ raise SystemExit(0 if 200 <= response.status < 300 else 1)
+except Exception:
+ raise SystemExit(1)
+PY
+ do
+ if (( SECONDS >= deadline )); then
+ printf '%s did not become ready within %s seconds.\n' "${label}" "${timeout_seconds}" >&2
+ exit 1
+ fi
+ sleep 0.25
+ done
+}
+
+SMOKE_ENV="$(mktemp "${TMPDIR:-/tmp}/reachy-local-stt-smoke.XXXXXX")"
+cat >"${SMOKE_ENV}" <