diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md
index 97ae86c27..189dbe91d 100644
--- a/docs/memory/architecture.md
+++ b/docs/memory/architecture.md
@@ -566,21 +566,25 @@ Package `services/compatibility/` mirrors the deterministic `canary/` library:
**Note**: Route ordering is critical — static routes (`/context-stats`, `/autonomy-status`) must be defined BEFORE the `/{name}` catch-all (Invariant #4).
-### Voice (5 endpoints)
+### Voice (6 endpoints)
| Method | Path | Description |
|--------|------|-------------|
-| POST | `/api/agents/{name}/voice/start` | Start Gemini Live voice session; `workspace_mode` enables panel tools |
+| POST | `/api/agents/{name}/voice/start` | Start Gemini Live voice session; `workspace_mode` enables panel tools. Resolves voice as request override → persisted `voice_name` → `Kore` (#28) |
| POST | `/api/agents/{name}/voice/stop` | Stop active voice session |
| GET/PUT | `/api/agents/{name}/voice/prompt` | Get/set per-agent voice system prompt |
+| GET/PUT | `/api/agents/{name}/voice/name` | Get (any accessor; returns `available_voices`/`default_voice`) / set (owner-only; 400 on a voice outside `GEMINI_VOICE_NAMES`) the persisted per-agent Gemini voice. Applies to both the voice overlay/workspace and outbound VoIP calls (#28) |
| GET | `/api/agents/{name}/voice/{session_id}/panel` | Canvas panel state for workspace mode (ownership-gated; empty state when session gone, #699) |
### VoIP Telephony (VOIP-001, #1056 — flag-gated, default OFF)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
-| GET/PUT/DELETE | `/api/agents/{name}/voip` | Owner | Twilio-voice binding status / configure / remove. 404 when `voip_available` off |
+| GET/PUT/DELETE | `/api/agents/{name}/voip` | Owner | Twilio-voice binding status / configure / remove. 404 when `voip_available` off. Re-PUT preserves the `enabled` flag (#28) |
+| PUT | `/api/agents/{name}/voip/enabled` | Owner | Enable/disable the binding without re-entering credentials (`{enabled: bool}`); 404 when no binding exists. Disabled ⇒ outbound calls refused (#28) |
| POST | `/api/agents/{name}/voip/call` | JWT/MCP (`AuthorizedAgent`) | Place outbound call; rate-limited + daily-capped; optional `Idempotency-Key`. Returns `{call_id, status:"ringing", twilio_call_sid}` |
| WS | `/api/voip/voice/{call_id}` | Call-bound ticket | Twilio Media Streams audio bridge — see [VoIP](#voip-telephony-voip-001-1056) |
+The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing tab (`components/VoipChannelPanel.vue`), shown only when the platform `voip_available` flag is true (frontend reads it via `stores/sessions.js`); the underlying CRUD/voice endpoints are OSS and ungated (#28).
+
### Activities (1 endpoint)
| Method | Path | Description |
|--------|------|-------------|
@@ -862,6 +866,7 @@ CREATE TABLE agent_ownership (
max_backlog_depth INTEGER DEFAULT 50, -- BACKLOG-001
group_auth_mode TEXT DEFAULT 'none',
voice_system_prompt TEXT,
+ voice_name TEXT, -- #28: persisted Gemini voice (NULL → 'Kore')
guardrails_config TEXT,
file_sharing_enabled INTEGER DEFAULT 0, -- FILES-001
circuit_breaker_enabled INTEGER DEFAULT 0, -- RELIABILITY-007 (#526): dispatch-breaker opt-in
diff --git a/docs/memory/feature-flows/voice-chat.md b/docs/memory/feature-flows/voice-chat.md
index a275d70f8..b67536de5 100644
--- a/docs/memory/feature-flows/voice-chat.md
+++ b/docs/memory/feature-flows/voice-chat.md
@@ -443,6 +443,25 @@ Returns current canvas panel state. Returns empty state (not 404) for non-existe
---
+## Persisted per-agent voice (trinity-enterprise#28)
+
+The Gemini voice was historically hardcoded to `"Kore"` (`routers/voice.py::_get_voice_name`).
+It is now a persisted per-agent setting, `agent_ownership.voice_name` (default `Kore`,
+an edition-agnostic OSS primitive like `voice_system_prompt`):
+
+- `GET/PUT /api/agents/{name}/voice/name` — GET returns the persisted voice plus
+ `available_voices`/`default_voice`; PUT is owner-only and validates against the
+ canonical `GEMINI_VOICE_NAMES` (`config.py`), shared with the frontend picker
+ list (`src/constants/voices.js`) and drift-guarded by a unit test.
+- **Resolution at session start** (`voice_start`): per-session request override
+ (`VoiceStartRequest.voice_name`) → persisted `voice_name` → `Kore`. The read
+ path (`db.get_voice_name`) falls back to `Kore` for an unset or no-longer-valid
+ persisted value.
+- The **Workspace** ephemeral picker (`AgentWorkspace.vue`) now defaults its
+ selection to the persisted voice; the picker list is the shared
+ `src/constants/voices.js` module. The persisted voice also drives outbound VoIP
+ calls — see `voip-telephony.md`.
+
## Related Documentation
- [Authenticated Chat Tab](./authenticated-chat-tab.md) — Existing chat implementation
diff --git a/docs/memory/feature-flows/voip-telephony.md b/docs/memory/feature-flows/voip-telephony.md
index 87ed81de1..21584b0fb 100644
--- a/docs/memory/feature-flows/voip-telephony.md
+++ b/docs/memory/feature-flows/voip-telephony.md
@@ -1,9 +1,10 @@
# Feature: VoIP Telephony — Outbound Calls over Gemini Live (VOIP-001)
-> **Status (2026-06-04)**: Phase 1 (outbound) implemented for #1056. Behind a
-> feature flag that is **OFF by default** (`VOIP_ENABLED`). Inbound (Phase 2)
-> and UI/observability (Phase 3) are not yet built. Real PSTN path is
-> manual-verify (needs a live Twilio voice number).
+> **Status (2026-06-23)**: Phase 1 (outbound) implemented for #1056. Behind a
+> feature flag that is **OFF by default** (`VOIP_ENABLED`). Phase 3 config UI +
+> persisted voice picker added (trinity-enterprise#28, OSS — see below). Inbound
+> (Phase 2) and call cost/duration observability are not yet built. Real PSTN
+> path is manual-verify (needs a live Twilio voice number).
## Overview
@@ -216,6 +217,29 @@ PSTN barge-in latency and telephone-speech (8kHz μ-law) STT quality are
unvalidated until real calls happen — Phase 1 logs the barge-in/transcript
signals so they can be measured on the first live calls.
+## Phase 3 — Config UI + persisted voice (trinity-enterprise#28)
+
+Delivered as a plain **OSS** feature (not entitlement-gated — a UI gate over a
+money-spending OSS backend would be cosmetic; gated on the existing
+`voip_available` platform flag instead):
+
+- **Panel**: `src/frontend/src/components/VoipChannelPanel.vue` (modeled on
+ `WhatsAppChannelPanel.vue`), mounted in `SharingPanel.vue` under
+ `v-if="sessionsStore.voipAvailable"` (`stores/sessions.js` now reads
+ `voip_available` from `/api/settings/feature-flags`). Create/update/remove the
+ binding over the existing `GET/PUT/DELETE /api/agents/{name}/voip`; the Auth
+ Token is write-only (never returned).
+- **Enable/disable**: `PUT /api/agents/{name}/voip/enabled` (`{enabled}`,
+ owner-only, 404 when no binding) flips `voip_bindings.enabled` without
+ re-entering credentials. The upsert no longer forces `enabled=1`, so re-saving
+ credentials **preserves** a disabled state (the call path already refuses
+ disabled bindings in `voip_service.py`).
+- **Persisted voice**: new OSS primitive `agent_ownership.voice_name`
+ (default `Kore`) via `GET/PUT /api/agents/{name}/voice/name` (PUT owner-only,
+ validated against `GEMINI_VOICE_NAMES`). `services/voip_service.py` reads it
+ per call instead of the old hardcoded `"Kore"`. See `voice-chat.md`.
+- **Tests**: `tests/unit/test_28_voip_voice_config.py`.
+
## Related Flows
- **Upstream**: `voice-chat.md` (the Gemini Live bridge this reuses unchanged),
diff --git a/docs/memory/learnings.md b/docs/memory/learnings.md
new file mode 100644
index 000000000..d08cda7b3
--- /dev/null
+++ b/docs/memory/learnings.md
@@ -0,0 +1,8 @@
+# Learnings — durable bug-class ledger
+
+Append-only. One entry per *class* of error/pattern worth knowing before the next
+plan or review. `/autoplan` reads this before planning; write for that reader.
+
+## 2026-06-23 — pitfall — Adding a column needs BOTH schema.py AND tables.py; parity test guards only one
+**Context**: `agent_ownership.voice_name` (trinity-enterprise#28). `tests/unit/test_schema_parity.py` compares `db/schema.py` (raw DDL) against `db/migrations.py` (the SQLite runner) — it does **not** import `db/tables.py` (the SQLAlchemy Core MetaData that Alembic autogenerates from and that every `db/*.py` accessor binds columns against). So a change that adds a column to `schema.py` + `migrations.py` but forgets `tables.py` keeps the parity test green, yet `select(agent_ownership.c.
)` blows up at runtime on the first call.
+**Lesson**: A schema change touches **four** files — `db/schema.py`, `db/tables.py`, a SQLite migration in `db/migrations.py`, and an Alembic revision under `db/../migrations/versions/`. Don't trust schema-parity to catch a missing `tables.py` Column. The real guard is a db-accessor unit test that executes a live `select(table.c.)` against a migrated DB (db_harness) — it fails loudly if `tables.py` was missed.
diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md
index a12cbabe1..0981db912 100644
--- a/docs/memory/requirements.md
+++ b/docs/memory/requirements.md
@@ -2840,9 +2840,41 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as
Phase 2: Twilio voice webhook + `X-Twilio-Signature` + inbound
number→agent resolution on the same table; an `inbound_number` column
is shipped up-front so Phase 2 is additive); opt-in destination
- allowlist (Phase 2); UI config surface, schedule-action trigger, and
- call cost/duration observability (Phase 3). Real PSTN path is
- manual-verify (needs a live Twilio voice number).
+ allowlist (Phase 2); schedule-action trigger and call cost/duration
+ observability (Phase 3). Real PSTN path is manual-verify (needs a live
+ Twilio voice number). (UI config surface delivered separately in §39.2.)
+
+### 39.2 Per-agent VoIP config UI + persisted voice (trinity-enterprise#28)
+- **Status**: 🚧 In Progress
+- **Implements**: Issue trinity-enterprise#28 (the Phase-3 "UI config surface"
+ deferred by #1056)
+- **Description**: A per-agent VoIP config panel in the agent Settings/Sharing
+ tab (`components/VoipChannelPanel.vue`) to create/update/remove the
+ `voip_bindings` row over the existing OSS `GET/PUT/DELETE /api/agents/{name}/voip`
+ endpoints (Twilio Account SID, write-only Auth Token, From number, optional
+ daily call cap), an **enable/disable toggle**, and a **persisted voice picker**.
+- **OSS, not entitlement-gated**: shipped as a plain OSS capability gated purely
+ on the existing `voip_available` platform flag (the frontend reads it via
+ `stores/sessions.js`). No enterprise entitlement, no `register_module`, no
+ `trinity-enterprise` submodule change — a deliberate simplification over the
+ issue's original "entitlement-gated" framing (a UI gate over an OSS,
+ money-spending backend would be cosmetic; gate is the platform flag instead).
+- **Enable/disable toggle**: `PUT /api/agents/{name}/voip/enabled`
+ (`{enabled: bool}`, owner-only, 404 when no binding) flips `voip_bindings.enabled`
+ without re-entering credentials; the call path already refuses disabled
+ bindings. Re-saving credentials via the binding PUT **preserves** the current
+ `enabled` state (the upsert no longer forces `enabled=1`).
+- **Persisted per-agent voice**: new edition-agnostic OSS primitive
+ `agent_ownership.voice_name` (default `Kore`, like `voice_system_prompt`) with
+ `GET/PUT /api/agents/{name}/voice/name` (PUT owner-only, validated against the
+ canonical `GEMINI_VOICE_NAMES`). Replaces the two hardcoded `"Kore"` sites
+ (`routers/voice.py::_get_voice_name`, `services/voip_service.py`), so the chosen
+ voice applies to **both** the in-app voice overlay/workspace and outbound VoIP
+ calls. Resolution precedence at a voice start: per-session request override →
+ persisted `voice_name` → `Kore`; the read path falls back to `Kore` for an
+ unset or no-longer-valid persisted value. The Workspace ephemeral picker
+ defaults to the persisted voice. Dual-track migration (SQLite `db/migrations.py`
+ + Alembic `0004_agent_ownership_voice_name` + `db/schema.py`/`db/tables.py`).
---
diff --git a/src/backend/config.py b/src/backend/config.py
index 0da9b3400..4e35e3b93 100644
--- a/src/backend/config.py
+++ b/src/backend/config.py
@@ -185,6 +185,14 @@
VOICE_MODEL = os.getenv("VOICE_MODEL") or "models/gemini-3.1-flash-live-preview"
VOICE_MAX_DURATION = int(os.getenv("VOICE_MAX_DURATION", "300")) # seconds
+# Per-agent voice selection (#28). Canonical set of Gemini Live prebuilt voices
+# offered by Trinity; the single source of truth shared by the persisted-voice
+# write validation and the read-path fallback (and mirrored by the frontend
+# picker). DEFAULT_VOICE_NAME is the historical hardcoded default and the
+# fallback for an unset or no-longer-valid persisted value.
+DEFAULT_VOICE_NAME = "Kore"
+GEMINI_VOICE_NAMES = ("Kore", "Zephyr", "Puck", "Aoede", "Charon", "Fenrir")
+
# Gemini text/audio models (#1130). Hardcoded `gemini-2.0-flash` was retired by
# Google (404 NOT_FOUND) with no config escape hatch — these env overrides make
# the next model retirement a config change instead of a code change. Same `or`
diff --git a/src/backend/database.py b/src/backend/database.py
index 018444ba0..b23813540 100644
--- a/src/backend/database.py
+++ b/src/backend/database.py
@@ -741,6 +741,12 @@ def get_voice_system_prompt(self, agent_name: str):
def set_voice_system_prompt(self, agent_name: str, prompt: str):
return self._agent_ops.set_voice_system_prompt(agent_name, prompt)
+ def get_voice_name(self, agent_name: str):
+ return self._agent_ops.get_voice_name(agent_name)
+
+ def set_voice_name(self, agent_name: str, voice_name):
+ return self._agent_ops.set_voice_name(agent_name, voice_name)
+
# =========================================================================
# MCP API Key Management (delegated to db/mcp_keys.py)
# =========================================================================
@@ -1960,6 +1966,9 @@ def update_voip_webhook_url(self, agent_name, webhook_url):
def delete_voip_binding(self, agent_name):
return self._voip_ops.delete_binding(agent_name)
+ def set_voip_enabled(self, agent_name, enabled):
+ return self._voip_ops.set_enabled(agent_name, enabled)
+
def create_voip_call_log(self, call_id, agent_name, to_number, chat_session_id=None,
initiated_by_user_id=None, initiated_by_email=None,
direction="outbound"):
diff --git a/src/backend/db/agents.py b/src/backend/db/agents.py
index 81c33d7d4..237630812 100644
--- a/src/backend/db/agents.py
+++ b/src/backend/db/agents.py
@@ -397,3 +397,36 @@ def set_voice_system_prompt(self, agent_name: str, prompt: Optional[str]) -> boo
.values(voice_system_prompt=prompt or None)
)
return result.rowcount > 0
+
+ # =========================================================================
+ # Voice Name (#28) — persisted per-agent Gemini Live voice
+ # =========================================================================
+
+ def get_voice_name(self, agent_name: str) -> str:
+ """Get the persisted Gemini voice for an agent.
+
+ Falls back to DEFAULT_VOICE_NAME ('Kore') when unset, and ALSO when the
+ persisted value is not in the current GEMINI_VOICE_NAMES set (a voice
+ removed after it was saved) — defense-in-depth so the call path never
+ hands Gemini an unusable voice (#28, reviewer M1).
+ """
+ from config import DEFAULT_VOICE_NAME, GEMINI_VOICE_NAMES
+
+ stmt = select(agent_ownership.c.voice_name).where(
+ agent_ownership.c.agent_name == agent_name,
+ agent_ownership.c.deleted_at.is_(None),
+ )
+ with get_engine().connect() as conn:
+ row = conn.execute(stmt).mappings().first()
+ value = row["voice_name"] if row else None
+ return value if value in GEMINI_VOICE_NAMES else DEFAULT_VOICE_NAME
+
+ def set_voice_name(self, agent_name: str, voice_name: Optional[str]) -> bool:
+ """Set the persisted Gemini voice for an agent (None clears it → default)."""
+ with get_engine().begin() as conn:
+ result = conn.execute(
+ update(agent_ownership)
+ .where(agent_ownership.c.agent_name == agent_name)
+ .values(voice_name=voice_name or None)
+ )
+ return result.rowcount > 0
diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py
index ec0b3e98b..55b3ee714 100644
--- a/src/backend/db/migrations.py
+++ b/src/backend/db/migrations.py
@@ -971,6 +971,22 @@ def _migrate_agent_ownership_voice_prompt(cursor, conn):
)
conn.commit()
+def _migrate_agent_ownership_voice_name(cursor, conn):
+ """Add voice_name column to agent_ownership (#28).
+
+ Persists the per-agent Gemini Live voice (e.g. 'Kore', 'Puck'). NULL means
+ unset — the getter falls back to DEFAULT_VOICE_NAME ('Kore'), preserving the
+ prior hardcoded behaviour for existing agents.
+ """
+ _safe_add_column(
+ cursor,
+ "agent_ownership",
+ "voice_name",
+ "ALTER TABLE agent_ownership ADD COLUMN voice_name TEXT",
+ )
+ conn.commit()
+
+
def _migrate_slack_channel_agents(cursor, conn):
"""Add multi-agent Slack support: workspace table + channel-agent bindings.
@@ -2564,4 +2580,5 @@ def _migrate_agent_compatibility_results_table(cursor, conn):
("operator_queue_cleared_at", _migrate_operator_queue_cleared_at),
("activities_created_index", _migrate_activities_created_index),
("agent_compatibility_results_table", _migrate_agent_compatibility_results_table),
+ ("agent_ownership_voice_name", _migrate_agent_ownership_voice_name),
]
diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py
index 79257d605..051252f34 100644
--- a/src/backend/db/schema.py
+++ b/src/backend/db/schema.py
@@ -90,6 +90,7 @@
max_backlog_depth INTEGER DEFAULT 50,
group_auth_mode TEXT DEFAULT 'none',
voice_system_prompt TEXT,
+ voice_name TEXT,
guardrails_config TEXT,
file_sharing_enabled INTEGER DEFAULT 0,
circuit_breaker_enabled INTEGER DEFAULT 0,
diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py
index 2648473a6..0d026de27 100644
--- a/src/backend/db/tables.py
+++ b/src/backend/db/tables.py
@@ -94,6 +94,7 @@ def process_bind_param(self, value, dialect):
Column("max_backlog_depth", Integer),
Column("group_auth_mode", Text),
Column("voice_system_prompt", Text),
+ Column("voice_name", Text),
Column("guardrails_config", Text),
Column("file_sharing_enabled", Integer),
Column("circuit_breaker_enabled", Integer),
diff --git a/src/backend/db/voip.py b/src/backend/db/voip.py
index 64a3da960..b54cf573a 100644
--- a/src/backend/db/voip.py
+++ b/src/backend/db/voip.py
@@ -96,7 +96,11 @@ def create_binding(
"webhook_secret": webhook_secret,
"daily_call_cap": cap,
"display_name": display_name,
- "enabled": 1,
+ # NOTE: `enabled` is deliberately NOT in the conflict set_ (#28).
+ # A fresh binding inserts enabled=1; re-saving credentials on an
+ # existing binding must PRESERVE the current enable state so an
+ # owner who disabled VoIP doesn't get it silently re-enabled by
+ # editing the from-number (reviewer H3). Toggle via set_enabled().
"updated_at": now,
},
)
@@ -186,6 +190,21 @@ def update_webhook_url(self, agent_name: str, webhook_url: str) -> None:
with get_engine().begin() as conn:
conn.execute(stmt)
+ def set_enabled(self, agent_name: str, enabled: bool) -> bool:
+ """Flip a binding's enabled flag without touching credentials (#28).
+
+ Returns False when no binding row exists for the agent (caller 404s) so
+ the UI never shows a disabled-but-nonexistent state.
+ """
+ stmt = (
+ update(voip_bindings)
+ .where(voip_bindings.c.agent_name == agent_name)
+ .values(enabled=1 if enabled else 0, updated_at=utc_now_iso())
+ )
+ with get_engine().begin() as conn:
+ result = conn.execute(stmt)
+ return result.rowcount > 0
+
def delete_binding(self, agent_name: str) -> bool:
stmt = delete(voip_bindings).where(voip_bindings.c.agent_name == agent_name)
with get_engine().begin() as conn:
diff --git a/src/backend/migrations/versions/0004_agent_ownership_voice_name.py b/src/backend/migrations/versions/0004_agent_ownership_voice_name.py
new file mode 100644
index 000000000..a335e542d
--- /dev/null
+++ b/src/backend/migrations/versions/0004_agent_ownership_voice_name.py
@@ -0,0 +1,30 @@
+"""Add voice_name column to agent_ownership (#28)
+
+Persists the per-agent Gemini Live voice on the PostgreSQL backend. Mirrors the
+SQLite ``agent_ownership_voice_name`` migration in ``db/migrations.py`` and the
+DDL in ``db/schema.py`` / MetaData in ``db/tables.py``.
+
+Fresh PG builds already get the column because ``0001_baseline`` iterates
+``db/schema.py:TABLES``. This revision exists so an *existing* PG deployment —
+stamped at an earlier revision and never re-running baseline — also picks the
+column up on ``alembic upgrade head``.
+
+Revision ID: 0004_agent_ownership_voice_name
+Revises: 0003_agent_compatibility_results
+Create Date: 2026-06-23
+"""
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "0004_agent_ownership_voice_name"
+down_revision = "0003_agent_compatibility_results"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TABLE agent_ownership ADD COLUMN IF NOT EXISTS voice_name TEXT")
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE agent_ownership DROP COLUMN IF EXISTS voice_name")
diff --git a/src/backend/routers/voice.py b/src/backend/routers/voice.py
index 3b35a0aaa..83466a706 100644
--- a/src/backend/routers/voice.py
+++ b/src/backend/routers/voice.py
@@ -19,9 +19,9 @@
from pydantic import BaseModel
from models import User
-from dependencies import get_current_user, get_authorized_agent
+from dependencies import get_current_user, get_authorized_agent, get_owned_agent
from database import db
-from config import GEMINI_API_KEY, VOICE_ENABLED
+from config import GEMINI_API_KEY, VOICE_ENABLED, DEFAULT_VOICE_NAME, GEMINI_VOICE_NAMES
from services.gemini_voice import voice_service, WORKSPACE_PANEL_INSTRUCTIONS
from services.agent_auth import agent_httpx_client
from services.docker_service import get_agent_container
@@ -213,6 +213,43 @@ async def set_voice_prompt(
return {"ok": True, "voice_system_prompt": prompt}
+@router.get("/api/agents/{name}/voice/name")
+async def get_voice_name(
+ name: str = Depends(get_authorized_agent),
+ current_user: User = Depends(get_current_user),
+):
+ """Get the agent's persisted voice + the selectable voice list (#28)."""
+ return {
+ "voice_name": db.get_voice_name(name),
+ "available_voices": list(GEMINI_VOICE_NAMES),
+ "default_voice": DEFAULT_VOICE_NAME,
+ }
+
+
+@router.put("/api/agents/{name}/voice/name")
+async def set_voice_name(
+ name: str = Depends(get_owned_agent),
+ current_user: User = Depends(get_current_user),
+ body: dict = None,
+):
+ """Set the agent's persisted voice (owner-only) (#28).
+
+ Validates against the canonical GEMINI_VOICE_NAMES set (400 on unknown). An
+ empty/None value clears the override, reverting to DEFAULT_VOICE_NAME.
+ """
+ voice_name = (body or {}).get("voice_name")
+ if voice_name in (None, ""):
+ db.set_voice_name(name, None)
+ return {"ok": True, "voice_name": db.get_voice_name(name)}
+ if voice_name not in GEMINI_VOICE_NAMES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown voice '{voice_name}'. Allowed: {', '.join(GEMINI_VOICE_NAMES)}",
+ )
+ db.set_voice_name(name, voice_name)
+ return {"ok": True, "voice_name": voice_name}
+
+
# ── WebSocket Handler ────────────────────────────────────────────────────────
@router.websocket("/ws/voice/{voice_session_id}")
@@ -435,9 +472,13 @@ async def _get_voice_system_prompt(agent_name: str) -> str:
def _get_voice_name(agent_name: str) -> str:
- """Get voice name for an agent (default: Kore)."""
- # For MVP, use a fixed default. Per-agent voice selection is Phase 3.
- return "Kore"
+ """Resolve the agent's persisted Gemini voice (#28).
+
+ Delegates to the DB accessor, which falls back to DEFAULT_VOICE_NAME ('Kore')
+ when unset or invalid. A per-session override (VoiceStartRequest.voice_name)
+ still takes precedence at the call site (see voice_start).
+ """
+ return db.get_voice_name(agent_name)
def _build_context_summary(chat_session_id: str) -> str:
diff --git a/src/backend/routers/voip.py b/src/backend/routers/voip.py
index af71c15f9..de0d40524 100644
--- a/src/backend/routers/voip.py
+++ b/src/backend/routers/voip.py
@@ -65,6 +65,10 @@ class VoipCallRequest(BaseModel):
process_transcript: bool = True
+class VoipEnabledRequest(BaseModel):
+ enabled: bool
+
+
# ── Helpers ──────────────────────────────────────────────────────────────────
def _require_enabled():
@@ -150,6 +154,33 @@ async def configure_voip_binding(
)
+@auth_router.put("/{agent_name}/voip/enabled", response_model=VoipBindingResponse)
+async def set_voip_enabled(
+ agent_name: OwnedAgentByName,
+ config: VoipEnabledRequest,
+):
+ """Enable/disable an agent's VoIP without re-entering credentials (owner-only, #28).
+
+ The call path already refuses disabled bindings (voip_service); this flips the
+ `enabled` flag in place. 404 when no binding exists so the UI never shows a
+ disabled-but-nonexistent state.
+ """
+ _require_enabled()
+ updated = db.set_voip_enabled(agent_name, config.enabled)
+ if not updated:
+ raise HTTPException(status_code=404, detail="No VoIP binding configured for this agent")
+ binding = db.get_voip_binding(agent_name)
+ return VoipBindingResponse(
+ agent_name=agent_name,
+ configured=True,
+ account_sid=binding["account_sid"],
+ from_number=binding["from_number"],
+ daily_call_cap=binding.get("daily_call_cap"),
+ display_name=binding.get("display_name"),
+ enabled=binding.get("enabled"),
+ )
+
+
@auth_router.delete("/{agent_name}/voip")
async def delete_voip_binding(agent_name: OwnedAgentByName):
"""Remove the Twilio voice binding for an agent (owner-only)."""
diff --git a/src/backend/services/voip_service.py b/src/backend/services/voip_service.py
index 82d62da54..4f598999b 100644
--- a/src/backend/services/voip_service.py
+++ b/src/backend/services/voip_service.py
@@ -200,7 +200,10 @@ async def place_outbound_call(
"user_id": initiator_user_id,
"user_email": initiator_email,
"system_prompt": system_prompt,
- "voice_name": "Kore",
+ # Persisted per-agent voice (#28); falls back to 'Kore' inside the
+ # accessor. Always set here so twilio_media_stream's intent.get(...,
+ # "Kore") fallback is dead code, not a silent override (reviewer C2).
+ "voice_name": db.get_voice_name(agent_name),
"to_number": dest,
"process_transcript": bool(process_transcript),
}
diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue
index 411cf40fc..75bda47f5 100644
--- a/src/frontend/src/components/SharingPanel.vue
+++ b/src/frontend/src/components/SharingPanel.vue
@@ -219,6 +219,13 @@
+
+
+
+
+
+
+
@@ -244,7 +251,9 @@ import PublicLinksPanel from './PublicLinksPanel.vue'
import SlackChannelPanel from './SlackChannelPanel.vue'
import TelegramChannelPanel from './TelegramChannelPanel.vue'
import WhatsAppChannelPanel from './WhatsAppChannelPanel.vue'
+import VoipChannelPanel from './VoipChannelPanel.vue'
import FileSharingPanel from './FileSharingPanel.vue'
+import { useSessionsStore } from '../stores/sessions'
const props = defineProps({
agentName: {
@@ -262,6 +271,11 @@ const emit = defineEmits(['agent-updated'])
const agentsStore = useAgentsStore()
const { showNotification } = useNotification()
+// VoIP panel visibility (#28) — gated purely on the platform `voip_available`
+// flag (VOIP_ENABLED && GEMINI_API_KEY). Cached, idempotent, fire-and-forget.
+const sessionsStore = useSessionsStore()
+sessionsStore.loadFeatureFlags()
+
// Create agent ref for composable
const agent = ref({ name: props.agentName, shares: props.shares })
diff --git a/src/frontend/src/components/VoipChannelPanel.vue b/src/frontend/src/components/VoipChannelPanel.vue
new file mode 100644
index 000000000..47f80da09
--- /dev/null
+++ b/src/frontend/src/components/VoipChannelPanel.vue
@@ -0,0 +1,341 @@
+
+
+
Voice Calls (Twilio / VoIP)
+
+ Connect a Twilio voice sender so this agent can place outbound phone calls.
+ Each agent brings its own Twilio account. Outbound only.
+
+
+
+
+
+ Loading...
+
+
+
+
+ Only the agent owner can manage voice-call settings.
+