# Configuration

RoohAI uses YAML configuration at two levels: a global `config.yaml` for the default pipeline, and per-agent configs in the `agents/` directory.

## Global Config (`config.yaml`)

The root config file defines the default pipeline and model parameters:

```yaml
pipeline:
  stt: whisper-tiny
  tts: piper
  llm: bedrock-claude
  vad: silero

models:
  whisper-tiny:
    type: stt
    class: whisper
    model_id: openai/whisper-tiny
    language: en

  piper:
    type: tts
    class: piper

  bedrock-claude:
    type: llm
    class: bedrock
    model_id: anthropic.claude-3-haiku-20240307-v1:0
    region: us-east-1

  silero:
    type: vad
    class: silero

audio:
  sample_rate: 16000
  channels: 1

server:
  host: 0.0.0.0
  port: 8000
```

### Sections

#### `pipeline`

Maps component types to model names defined in the `models` section, plus voice pipeline settings:

```yaml
pipeline:
  stt: <model-name>        # Speech-to-Text
  llm: <model-name>        # Language Model
  tts: <model-name>        # Text-to-Speech
  vad: <model-name>        # Voice Activity Detection
  vad_threshold: 0.5       # 0.0-1.0 — VAD sensitivity (lower = more sensitive)
  barge_in_enabled: true   # Allow users to interrupt AI speech (default: true)
  silence_duration_ms: 1000 # Milliseconds of silence before ending an utterance
```

| Field | Default | Description |
|-------|---------|-------------|
| `vad_threshold` | `0.7` | Speech detection sensitivity. Lower values trigger on quieter speech; higher values require louder/clearer speech. Can be overridden at runtime per session via `set_vad_threshold` message. |
| `barge_in_enabled` | `true` | When `true`, user speech during AI generation cancels the in-flight response. When `false`, the AI continues speaking even if the user talks. |
| `silence_duration_ms` | `1000` | How long to wait (in milliseconds) after the user stops speaking before treating the utterance as complete. Higher values give users more time to pause mid-thought. For interview agents, values of 2000-3000ms are recommended. |

#### How `vad_threshold` and `silence_duration_ms` Work Together

These two settings are complementary — they control different stages of the same VAD decision loop:

1. **Each ~32ms audio chunk** is scored by the VAD model (Silero) → returns a speech probability (0.0–1.0)
2. **`vad_threshold`** decides per-chunk: *"Is this speech or silence?"* — if `speech_prob >= threshold`, the chunk is classified as speech
3. **`silence_duration_ms`** decides across chunks: *"Has the user finished talking?"* — once consecutive silence chunks exceed this duration, the utterance is considered complete and sent to STT → LLM → TTS

```
Audio chunks ──► VAD model ──► speech probability
                                    │
                          ┌─────────┴─────────┐
                     prob >= threshold     prob < threshold
                      (= speech)            (= silence)
                          │                     │
                    buffer audio,          increment silence
                    reset silence count        counter
                                                │
                                    silence count >= silence_duration_ms?
                                          │              │
                                         YES             NO
                                          │              │
                                   end utterance    keep waiting
                                   (send to STT)
```

**Tuning guide:**

| Scenario | `vad_threshold` | `silence_duration_ms` |
|----------|----------------|-----------------------|
| Fast chatbot (quiet room) | `0.5` | `500–800` |
| General assistant | `0.7` (default) | `1000` (default) |
| Noisy environment | `0.8–0.9` | `1000` |
| Interview / thinking time | `0.4–0.5` | `2000–3000` |
| Accessibility (soft speech) | `0.3` | `1500` |

Both settings have sensible defaults (`0.7` and `1000ms`) so neither is required in your config — but tuning them together gives you precise control over turn-taking behaviour.

#### `models`

Each model entry requires `type` and `class`, plus model-specific parameters:

```yaml
models:
  <model-name>:
    type: stt | tts | llm | vad
    class: whisper | wav2vec2 | deepgram | speecht5 | bark | piper | cartesia | bedrock | local | silero
    # ... model-specific parameters
```

The `class` field maps to a registry name. The `type` field determines which base class the model must implement.

#### `audio`

```yaml
audio:
  sample_rate: 16000   # Internal processing sample rate
  channels: 1          # Mono audio
```

#### `server`

```yaml
server:
  host: 0.0.0.0
  port: 8000
```

## Agent Configs (`agents/*.yaml`)

Each agent is a self-contained configuration file stored as `~/.roohai/agents/{name}.yaml`.

### Agent Naming Rules

The agent `name` is used as a URL path segment (`/api/agents/{name}/activate`), a filename (`{name}.yaml`), and an environment variable prefix (`ROOHAI_SECRET_{NAME}_`), so it must be safe for all three:

- **Allowed characters:** letters (`A-Z`, `a-z`), digits (`0-9`), hyphens (`-`), underscores (`_`)
- **Must start with** a letter or digit
- **Length:** 1–64 characters

| Valid | Invalid | Why |
|-------|---------|-----|
| `my-agent` | `my agent` | spaces not allowed |
| `Weather_Bot` | `../etc` | slashes, dots not allowed |
| `Agent-v2` | `.hidden` | must start with letter or digit |
| `a` | `` | empty string |

### Example

```yaml
name: my-cloud-agent
created_at: '2026-03-01T09:11:39.289009+00:00'
status: active      # Only one agent can be "active" at a time
llm_streaming: true # Stream LLM tokens (default), false for batch
system_prompt: You are a helpful voice assistant. Be concise and friendly.

pipeline:
  stt: deepgram
  llm: bedrock-claude
  tts: cartesia
  vad: silero

models:
  deepgram:
    class: deepgram
    type: stt
    model: nova-3
    language: en

  bedrock-claude:
    class: bedrock-claude
    type: llm
    model_id: anthropic.claude-3-haiku-20240307-v1:0
    region: us-east-1
    auth_mode: environment

  cartesia:
    class: cartesia
    type: tts
    model_id: sonic-2
    voice_id: a0e99841-438c-4a64-b679-ae501e7d6091
    language: en
    sample_rate: '24000'

  silero:
    class: silero
    type: vad
```

### LLM Streaming

The `llm_streaming` field controls whether the LLM streams tokens incrementally or waits for the full response:

```yaml
llm_streaming: true    # default — stream tokens, start TTS on first sentence
llm_streaming: false   # wait for full LLM response, then synthesize all at once
```

| Mode | Behavior | Latency | Frontend Display |
|------|----------|---------|------------------|
| `true` (streaming) | Tokens arrive progressively; TTS starts on first completed sentence while LLM is still generating | Lower perceived latency | Text appears word-by-word in chat bubble (`llm_response_chunk` messages) |
| `false` (batch) | Full response arrives at once; TTS starts after LLM finishes | Higher latency | Full text appears at once (`llm_response` message) |

Streaming requires the LLM model to implement `chat_stream()`. The built-in `BedrockLLM` supports streaming. Models that don't override `chat_stream()` (like `LocalLLM`) fall back to yielding the full `chat()` result as one chunk, which behaves identically to batch mode regardless of this setting.

If omitted, defaults to `true`.

### Barge-In Control

Barge-in lets users interrupt the AI mid-response by speaking. It is enabled by default and can be controlled per agent.

#### Disabling Barge-In

```yaml
pipeline:
  barge_in_enabled: false  # AI will not be interrupted when user speaks
```

When disabled, the AI completes its full response even if the user starts talking. VAD still detects speech (for the next turn) but does not cancel in-flight generation.

#### VAD Threshold

The `vad_threshold` setting controls how sensitive barge-in detection is:

```yaml
pipeline:
  vad_threshold: 0.3   # Very sensitive — triggers on quiet speech
  vad_threshold: 0.7   # Default — balanced
  vad_threshold: 0.9   # Less sensitive — requires clear, loud speech
```

Clients can also adjust the threshold at runtime without reconnecting:

```javascript
// WebSocket
ws.send(JSON.stringify({ type: "set_vad_threshold", value: 0.8 }));

// WebRTC (via data channel)
dataChannel.send(JSON.stringify({ type: "set_vad_threshold", value: 0.8 }));
```

#### Barge-In Hook

You can register a callback that fires whenever barge-in occurs. This is useful for analytics, saving partial responses, or adapting agent behaviour.

**Programmatically:**

```python
async def on_barge_in(*, session_id=None, generation_id=0):
    print(f"User interrupted generation {generation_id} in session {session_id}")

pipeline.set_barge_in_hook(on_barge_in)
```

The hook receives:
- `session_id` — the WebSocket/WebRTC connection UUID
- `generation_id` — monotonically increasing ID for the interrupted generation

Pass `None` to remove the hook:

```python
pipeline.set_barge_in_hook(None)
```

See the `examples/barge-in-hook/` directory for a complete working example.

### Transport

The `transport` field controls how audio is exchanged between the browser and server:

```yaml
transport: websocket   # default — AudioWorklet + base64 JSON
transport: webrtc      # RTCPeerConnection + Opus codec
```

| Transport | Audio Encoding | Latency | Browser Support |
|-----------|---------------|---------|-----------------|
| `websocket` | PCM16 base64 over JSON | ~50-100ms | All modern browsers |
| `webrtc` | Opus via RTCPeerConnection | ~20-40ms | All modern browsers |

If omitted, defaults to `"websocket"` for backward compatibility.

### System Prompt

Every agent requires a `system_prompt` field. This is the instruction passed to the LLM on every conversation turn, defining the agent's persona and behavior:

```yaml
system_prompt: >
  You are a pirate captain. Always respond in pirate speak.
  Keep your answers short and entertaining.
```

The system prompt is:
- **Required** when creating an agent (via the API or the wizard UI)
- Stored in the agent's YAML config
- Loaded into the pipeline when the agent is activated
- Passed to the LLM's `chat()` method on every call along with the connection's `session_id`

### Agent Status

- `active` — This agent's models are loaded in the pipeline
- `inactive` — Saved but not in use

Only one agent can be active at a time. Activating an agent sets all others to `inactive`.

### Creating Agents Programmatically

```python
from roohai.agents import save_agent, get_agent, list_agents, delete_agent

# Create
config = {
    "name": "my-agent",
    "status": "inactive",
    "system_prompt": "You are a helpful voice assistant.",
    "pipeline": {"stt": "deepgram", "llm": "bedrock-claude", "tts": "piper", "vad": "silero"},
    "models": {
        "deepgram": {"class": "deepgram", "type": "stt", "model": "nova-3"},
        "bedrock-claude": {"class": "bedrock-claude", "type": "llm"},
        "piper": {"class": "piper", "type": "tts", "voice": "en_US-lessac-medium"},
        "silero": {"class": "silero", "type": "vad"},
    },
}
save_agent(config)

# List
agents = list_agents()  # Returns list of dicts

# Read
agent = get_agent("my-agent")  # Returns dict or None

# Delete
delete_agent("my-agent")  # Returns True if existed
```

## Secrets Management

API keys and credentials are stored separately from agent configs in `~/.roohai/secrets.yaml` with `0600` file permissions.

```python
from roohai.secrets import set_agent_secrets, get_agent_secrets, delete_agent_secrets

# Store secrets for an agent
set_agent_secrets("my-agent", {
    "deepgram": {"api_key": "dg-xxxx"},
    "cartesia": {"api_key": "sk-xxxx"},
})

# Retrieve
secrets = get_agent_secrets("my-agent")
# {"deepgram": {"api_key": "dg-xxxx"}, "cartesia": {"api_key": "sk-xxxx"}}

# Clean up
delete_agent_secrets("my-agent")
```

**How secrets merge into configs:** When an agent is activated, the server reads the agent's secrets and merges them into the model configs before creating model instances. This means the agent YAML files never contain sensitive data.

## Environment Variables

Cloud models check for environment variables as fallbacks when no API key is provided in config:

| Variable | Used By |
|----------|---------|
| `AWS_ACCESS_KEY_ID` | BedrockLLM |
| `AWS_SECRET_ACCESS_KEY` | BedrockLLM |
| `AWS_DEFAULT_REGION` | BedrockLLM |
| `AWS_BEARER_TOKEN_BEDROCK` | BedrockLLM (when `auth_mode: api_key`) |
| `DEEPGRAM_API_KEY` | DeepgramSTT |
| `CARTESIA_API_KEY` | CartesiaTTS |
| `ROOHAI_CORS_ORIGINS` | Server CORS policy |

## Security

### CORS (Cross-Origin Resource Sharing)

By default, the RoohAI server allows requests from **all origins** (`*`). To restrict access to specific domains, use the `--cors-origins` CLI flag or the `ROOHAI_CORS_ORIGINS` environment variable.

#### CLI flag

```bash
roohai --cors-origins "https://myapp.com,https://staging.myapp.com"
```

#### Environment variable

```bash
export ROOHAI_CORS_ORIGINS="https://myapp.com,https://staging.myapp.com"
roohai
```

This is useful for Docker, systemd, or CI/CD deployments where CLI flags are less convenient.

#### Behavior

| Setting | Effect |
|---------|--------|
| Not set / `"*"` | All origins allowed (default, suitable for local development) |
| Comma-separated URLs | Only listed origins can make cross-origin requests |

The CLI flag takes precedence when both the flag and environment variable are set.

#### Examples

```bash
# Allow only production domain
roohai --cors-origins "https://myapp.com"

# Allow multiple domains
roohai --cors-origins "https://myapp.com,https://admin.myapp.com"

# Restrict in Docker
docker run -e ROOHAI_CORS_ORIGINS="https://myapp.com" roohai
```

### Authentication (JWT / JWKS)

RoohAI supports optional JWT authentication using any OIDC-compliant identity provider (Supabase, Cognito, Auth0, Okta, etc.). When enabled, all protected routes require a valid Bearer token. Auth is **off by default** — no changes needed for local development.

#### Enabling Authentication

Set the JWKS endpoint URL via environment variable or CLI flag:

```bash
# Environment variable
export ROOHAI_AUTH_JWKS_URL="https://myproject.supabase.co/auth/v1/.well-known/jwks.json"
roohai

# CLI flag
roohai --auth-jwks-url "https://myproject.supabase.co/auth/v1/.well-known/jwks.json"
```

Optionally restrict by issuer and/or audience:

```bash
export ROOHAI_AUTH_ISSUER="https://myproject.supabase.co/auth/v1"
export ROOHAI_AUTH_AUDIENCE="authenticated"
```

Or via CLI:

```bash
roohai --auth-jwks-url "..." --auth-issuer "..." --auth-audience "authenticated"
```

#### Configuration Reference

| Variable / Flag | Description |
|----------------|-------------|
| `ROOHAI_AUTH_JWKS_URL` / `--auth-jwks-url` | JWKS endpoint URL. Setting this enables auth. |
| `ROOHAI_AUTH_ISSUER` / `--auth-issuer` | Expected `iss` claim (optional). Rejects tokens from other issuers. |
| `ROOHAI_AUTH_AUDIENCE` / `--auth-audience` | Expected `aud` claim (optional). Rejects tokens for other audiences. |

#### Protected vs Open Routes

| Route | Auth |
|-------|------|
| `/api/webrtc/offer`, `/api/webrtc/config`, `/api/webrtc/ice` | Protected |
| `/api/models/swap`, `/api/models` | Protected |
| `/ws/voice-stream`, `/ws/agent-prepare` | Protected (handshake) |
| `/api/catalog`, `/api/agents/*` | Protected (router dep) |
| `/api/health` | Always open |
| `/api/docs`, `/api/docs/search`, `/guide/*`, `/`, `/static/*` | Always open |

#### REST Endpoints

Send a standard `Authorization: Bearer <token>` header:

```bash
curl -H "Authorization: Bearer eyJhbG..." https://agent.example.com/api/models
```

#### WebSocket Endpoints

Pass the token as a `?token=` query parameter (WebSocket headers are not widely supported in browsers):

```
wss://agent.example.com/ws/voice-stream?token=eyJhbG...
```

Alternatively, the `Authorization: Bearer <token>` header is checked as a fallback.

Invalid or missing tokens result in a WebSocket close with code **4001**.

#### Provider Examples

**Supabase:**
```bash
export ROOHAI_AUTH_JWKS_URL="https://<project>.supabase.co/auth/v1/.well-known/jwks.json"
export ROOHAI_AUTH_AUDIENCE="authenticated"
```

**AWS Cognito:**
```bash
export ROOHAI_AUTH_JWKS_URL="https://cognito-idp.<region>.amazonaws.com/<pool-id>/.well-known/jwks.json"
export ROOHAI_AUTH_AUDIENCE="<app-client-id>"
```

**Auth0:**
```bash
export ROOHAI_AUTH_JWKS_URL="https://<tenant>.auth0.com/.well-known/jwks.json"
export ROOHAI_AUTH_AUDIENCE="https://api.example.com"
export ROOHAI_AUTH_ISSUER="https://<tenant>.auth0.com/"
```

#### Installation

JWT validation requires the `pyjwt[crypto]` package. Install via the `auth` extra:

```bash
pip install roohai[auth]
```

If `ROOHAI_AUTH_JWKS_URL` is set but `pyjwt` is not installed, the server will raise a clear `ImportError` at startup.

## TTS Chunking

Long LLM responses are split for TTS to avoid synthesis failures. The `TTS_MAX_CHARS` constant (250 characters) controls the maximum text per synthesis call. Text is split at sentence boundaries and short fragments are merged with the previous sentence.
