Merge branch 'feat/headless-widget' of https://github.com/dograh-hq/dograh into feat/headless-widget

This commit is contained in:
Abhishek Kumar 2026-05-07 12:17:14 +05:30
commit 85dd2f915b
36 changed files with 1063 additions and 250 deletions

View file

@ -15,6 +15,7 @@ concurrency:
jobs:
pytest:
if: ${{ !startsWith(github.head_ref, 'release-please--') }}
runs-on: ubuntu-latest
timeout-minutes: 30

View file

@ -1,14 +1,16 @@
name: Docs OpenAPI drift check
name: Pre-PR drift check
on:
pull_request:
branches: [main]
paths:
- "api/**"
- "ui/**"
- "pipecat/**"
- "scripts/dump_docs_openapi.py"
- "scripts/format.sh"
- "docs/api-reference/openapi.json"
- ".github/workflows/docs-openapi-drift.yml"
- ".github/workflows/pre-pr-drift-check.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -16,6 +18,7 @@ concurrency:
jobs:
drift-check:
if: ${{ !startsWith(github.head_ref, 'release-please--') }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
@ -44,6 +47,29 @@ jobs:
- name: Install api and pipecat dependencies
run: ./scripts/setup_requirements.sh --dev
- name: Install ui dependencies
working-directory: ui
run: npm ci
- name: Run scripts/format.sh
run: ./scripts/format.sh
- name: Check for Python format/lint drift
run: |
if ! git diff --exit-code api; then
echo "::error::Python files are not formatted. Run './scripts/format.sh' locally and commit the result before raising the PR."
exit 1
fi
echo "Python format/lint is clean."
- name: Check for UI lint drift
run: |
if ! git diff --exit-code ui; then
echo "::error::UI files have outstanding lint fixes. Run 'npm run fix-lint' inside ui/ locally and commit the result before raising the PR."
exit 1
fi
echo "UI lint is clean."
- name: Dump OpenAPI spec
env:
DATABASE_URL: postgresql+asyncpg://dummy:dummy@localhost/dummy
@ -53,7 +79,7 @@ jobs:
DEPLOYMENT_MODE: oss
run: python -u -m scripts.dump_docs_openapi
- name: Check for drift
- name: Check for OpenAPI drift
run: |
if ! git diff --exit-code docs/api-reference/openapi.json; then
echo "::error::docs/api-reference/openapi.json is out of sync with the FastAPI app."
@ -70,5 +96,5 @@ jobs:
with:
payload: |
{
"text": "❌ Dograh Docs OpenAPI drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
"text": "❌ Dograh pre-PR drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
}

View file

@ -0,0 +1,35 @@
"""add campaign logs column
Revision ID: 6499c608d0f6
Revises: a2355fc6bdc1
Create Date: 2026-05-05 17:25:49.235730
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6499c608d0f6"
down_revision: Union[str, None] = "a2355fc6bdc1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"campaigns",
sa.Column(
"logs", sa.JSON(), server_default=sa.text("'[]'::json"), nullable=False
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("campaigns", "logs")
# ### end Alembic commands ###

View file

@ -1,3 +1,4 @@
import json
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
@ -134,35 +135,6 @@ class CampaignClient(BaseDBClient):
await session.refresh(campaign)
return campaign
async def update_campaign_progress(
self,
campaign_id: int,
processed_rows: int,
failed_rows: int,
organization_id: int,
) -> None:
"""Update campaign progress counters"""
async with self.async_session() as session:
query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
result = await session.execute(query)
campaign = result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
campaign.processed_rows = processed_rows
campaign.failed_rows = failed_rows
campaign.updated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
async def get_campaign_runs(
self,
campaign_id: int,
@ -452,6 +424,48 @@ class CampaignClient(BaseDBClient):
await session.refresh(campaign)
return campaign
async def append_campaign_log(
self,
campaign_id: int,
level: str,
event: str,
message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
"""Append a timestamped entry to the campaign's logs JSON array.
Uses a SQL-side jsonb concat so concurrent writers do not clobber
each other's entries.
"""
entry: Dict[str, Any] = {
"ts": datetime.now(UTC).isoformat(),
"level": level,
"event": event,
"message": message,
}
if details:
entry["details"] = details
async with self.async_session() as session:
await session.execute(
text(
"UPDATE campaigns "
"SET logs = (logs::jsonb || CAST(:entry AS jsonb))::json, "
" updated_at = :now "
"WHERE id = :campaign_id"
),
{
"entry": json.dumps([entry]),
"now": datetime.now(UTC),
"campaign_id": campaign_id,
},
)
try:
await session.commit()
except Exception:
await session.rollback()
raise
# QueuedRun methods
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
"""Bulk create queued runs"""

View file

@ -683,6 +683,16 @@ class CampaignModel(Base):
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
# Append-only timestamped log entries for state transitions, failures,
# and circuit-breaker events. Surfaced in the UI so operators can see
# why a campaign moved to paused/failed without digging through logs.
logs = Column(
JSON,
nullable=False,
default=list,
server_default=text("'[]'::json"),
)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
started_at = Column(DateTime(timezone=True), nullable=True)

View file

@ -1,6 +1,6 @@
import json
from datetime import datetime
from typing import List, Optional
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
@ -172,6 +172,20 @@ class UpdateCampaignRequest(BaseModel):
circuit_breaker: Optional[CircuitBreakerConfigRequest] = None
class CampaignLogEntryResponse(BaseModel):
"""A single timestamped entry from the campaign's append-only log.
Surfaced in the UI so operators can see why a campaign moved to
paused / failed without digging through server logs.
"""
ts: str
level: str
event: str
message: str
details: Optional[Dict[str, Any]] = None
class CampaignResponse(BaseModel):
id: int
name: str
@ -196,6 +210,7 @@ class CampaignResponse(BaseModel):
redialed_campaign_id: Optional[int] = None
telephony_configuration_id: Optional[int] = None
telephony_configuration_name: Optional[str] = None
logs: List[CampaignLogEntryResponse] = Field(default_factory=list)
class CampaignsResponse(BaseModel):
@ -298,6 +313,11 @@ def _build_campaign_response(
redialed_campaign_id=redialed_campaign_id,
telephony_configuration_id=campaign.telephony_configuration_id,
telephony_configuration_name=telephony_configuration_name,
logs=[
CampaignLogEntryResponse(**entry)
for entry in (campaign.logs or [])
if isinstance(entry, dict)
],
)

View file

@ -16,7 +16,7 @@ from fastapi import (
)
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
from starlette.websockets import WebSocketDisconnect
from api.db import db_client
@ -30,7 +30,6 @@ from api.services.telephony.call_transfer_manager import get_call_transfer_manag
from api.services.telephony.factory import (
get_all_telephony_providers,
get_default_telephony_provider,
get_telephony_provider,
get_telephony_provider_by_id,
get_telephony_provider_for_run,
)
@ -874,110 +873,6 @@ async def handle_inbound_telephony(
return generic_hangup_response()
class TransferCallRequest(BaseModel):
"""Request model for initiating a call transfer."""
destination: str # E.164 format phone number (required)
organization_id: int # Organization ID for provider configuration
transfer_id: str # Unique identifier for tracking this transfer
conference_name: str # Conference name for the transfer
timeout: Optional[int] = 20 # seconds to wait for answer
@field_validator("destination")
@classmethod
def validate_destination(cls, destination: str) -> str:
"""Validate destination is in E.164 format."""
import re
if not destination or not destination.strip():
raise ValueError("Destination phone number is required")
E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$"
if not re.match(E164_PHONE_REGEX, destination.strip()):
raise ValueError(
f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)"
)
return destination.strip()
@router.post("/call-transfer")
async def initiate_call_transfer(request: TransferCallRequest):
"""Initiate a call transfer via the telephony provider.
This endpoint only initiates the outbound call. Transfer context
(original_call_sid, etc.) is stored by the caller
before invoking this endpoint.
"""
logger.info(
f"Starting call transfer to {request.destination} with transfer_id: {request.transfer_id}"
)
try:
try:
provider = await get_telephony_provider(request.organization_id)
except ValueError as e:
logger.error(f"Transfer provider validation failed: {e}")
raise HTTPException(
status_code=400, detail=f"Call transfer not supported: {str(e)}"
)
if not provider.supports_transfers():
raise HTTPException(
status_code=400,
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers",
)
if not provider.validate_config():
logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid")
raise HTTPException(
status_code=400,
detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers",
)
logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider")
try:
transfer_result = await provider.transfer_call(
destination=request.destination,
transfer_id=request.transfer_id,
conference_name=request.conference_name,
timeout=request.timeout,
)
except NotImplementedError as e:
logger.error(
f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}"
)
raise HTTPException(
status_code=400,
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers",
)
except Exception as e:
logger.error(f"Provider transfer call failed: {e}")
raise HTTPException(
status_code=500, detail=f"Transfer call failed: {str(e)}"
)
call_sid = transfer_result.get("call_sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
logger.debug(f"Transfer result: {transfer_result}")
return {
"status": "transfer_initiated",
"call_id": call_sid,
"message": f"Calling {request.destination}...",
"transfer_id": request.transfer_id,
"provider": provider.PROVIDER_NAME,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error during transfer call: {e}")
raise HTTPException(
status_code=500, detail=f"Internal error during transfer: {str(e)}"
)
@router.post("/transfer-result/{transfer_id}")
async def complete_transfer_function_call(transfer_id: str, request: Request):
"""Webhook endpoint to complete the function call with transfer result.

View file

@ -345,7 +345,12 @@ class CampaignCallDispatcher:
)
# Record call initiation failure in circuit breaker
await circuit_breaker.record_and_evaluate(campaign.id, is_failure=True)
await circuit_breaker.record_and_evaluate(
campaign.id,
is_failure=True,
workflow_run_id=workflow_run.id,
reason="call_initiation_failed",
)
# Release concurrent slot on failure
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id)
@ -459,13 +464,18 @@ class CampaignCallDispatcher:
await asyncio.sleep(1)
async def acquire_from_number(
self, organization_id: int, timeout: float = 60
self, organization_id: int, timeout: float = 600
) -> Optional[str]:
"""
Acquire a from_number from the pool with retry.
Waits up to timeout seconds, polling every 1s.
Returns the phone number or None if timeout is exceeded.
Args:
organization_id: ID of the organization for which to acquire the from_number.
timeout: Maximum time in seconds to wait for a from_number before giving up.
Returns:
The acquired phone number as a string, or None if timeout is exceeded.
"""
wait_start = time.time()

View file

@ -383,6 +383,20 @@ class CampaignOrchestrator:
f"pausing campaign. Stats: {stats}"
)
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="warning",
event="circuit_breaker_tripped",
message=(
f"Paused at scheduling: failure rate "
f"{stats['failure_rate']:.2%} "
f"({stats['failure_count']}/"
f"{stats['failure_count'] + stats['success_count']}) "
f"exceeded threshold {stats['threshold']:.2%} "
f"in {stats['window_seconds']}s window"
),
details=stats,
)
await self.publisher.publish_circuit_breaker_tripped(
campaign_id=campaign_id,
failure_rate=stats["failure_rate"],

View file

@ -3,10 +3,15 @@
Uses two Redis sorted sets (ZSETs) per campaign one for failures, one for
successes as sliding windows. ZCARD gives O(1) counts without iterating
members, keeping the Lua scripts simple.
A separate capped Redis list (``cb_recent_failures:{campaign_id}``) stores the
last N failing ``{workflow_run_id, reason, ts}`` entries so the campaign log
written when the breaker trips can show *which* calls pushed it over.
"""
import json
import time
from typing import Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import redis.asyncio as aioredis
from loguru import logger
@ -15,6 +20,11 @@ from api.constants import DEFAULT_CIRCUIT_BREAKER_CONFIG, REDIS_URL
from api.db import db_client
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
# Cap on the number of recent failure entries kept per campaign — large enough
# to be useful for debugging a trip, small enough that the JSON details stay
# bounded.
MAX_RECENT_FAILURES = 20
class CircuitBreaker:
"""Sliding window circuit breaker for campaign call failures."""
@ -35,6 +45,60 @@ class CircuitBreaker:
"""Return (failures_key, successes_key) for a campaign."""
return f"cb_failures:{campaign_id}", f"cb_successes:{campaign_id}"
@staticmethod
def _recent_failures_key(campaign_id: int) -> str:
"""Return the Redis key used for the capped recent-failures list."""
return f"cb_recent_failures:{campaign_id}"
async def _push_recent_failure(
self,
campaign_id: int,
workflow_run_id: int,
reason: Optional[str],
) -> None:
"""Push a failure entry onto the capped recent-failures list."""
redis_client = await self._get_redis()
key = self._recent_failures_key(campaign_id)
entry = json.dumps(
{
"workflow_run_id": workflow_run_id,
"reason": reason,
"ts": time.time(),
}
)
try:
await redis_client.lpush(key, entry)
await redis_client.ltrim(key, 0, MAX_RECENT_FAILURES - 1)
# Keep this list around as long as the sliding window plus a buffer.
await redis_client.expire(
key,
DEFAULT_CIRCUIT_BREAKER_CONFIG["window_seconds"] + 60,
)
except Exception as e:
# Never let recent-failure bookkeeping disrupt the call path.
logger.error(
f"Failed to record recent failure for campaign {campaign_id}: {e}"
)
async def _get_recent_failures(self, campaign_id: int) -> List[Dict[str, Any]]:
"""Return the recent-failures list (most-recent first)."""
redis_client = await self._get_redis()
key = self._recent_failures_key(campaign_id)
try:
entries = await redis_client.lrange(key, 0, -1)
except Exception as e:
logger.error(
f"Failed to read recent failures for campaign {campaign_id}: {e}"
)
return []
decoded: List[Dict[str, Any]] = []
for raw in entries:
try:
decoded.append(json.loads(raw))
except (TypeError, ValueError):
continue
return decoded
async def record_call_outcome(
self,
campaign_id: int,
@ -227,13 +291,25 @@ class CircuitBreaker:
logger.error(f"Circuit breaker check error for campaign {campaign_id}: {e}")
return False, None
async def record_and_evaluate(self, campaign_id: int, is_failure: bool) -> None:
async def record_and_evaluate(
self,
campaign_id: int,
is_failure: bool,
*,
workflow_run_id: Optional[int] = None,
reason: Optional[str] = None,
) -> None:
"""Record a call outcome, and if the breaker trips, pause the campaign.
This is the main entry point called from telephony status callbacks.
It handles fetching campaign config, recording the outcome, and
pausing + publishing an event if the breaker trips.
``workflow_run_id`` and ``reason`` are optional but should be supplied
on failures: they are appended to a capped Redis list so the campaign
log entry written on trip can name the calls that pushed the breaker
over the threshold.
Exceptions are caught internally so this never disrupts the caller.
"""
try:
@ -245,6 +321,13 @@ class CircuitBreaker:
if campaign.orchestrator_metadata:
cb_config = campaign.orchestrator_metadata.get("circuit_breaker", {})
if is_failure and workflow_run_id is not None:
await self._push_recent_failure(
campaign_id=campaign_id,
workflow_run_id=workflow_run_id,
reason=reason,
)
tripped, stats = await self.record_call_outcome(
campaign_id=campaign_id,
is_failure=is_failure,
@ -257,7 +340,22 @@ class CircuitBreaker:
f"pausing campaign. Stats: {stats}"
)
recent_failures = await self._get_recent_failures(campaign_id)
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="warning",
event="circuit_breaker_tripped",
message=(
f"Paused: failure rate {stats['failure_rate']:.2%} "
f"({stats['failure_count']}/"
f"{stats['failure_count'] + stats['success_count']}) "
f"exceeded threshold {stats['threshold']:.2%} "
f"in {stats['window_seconds']}s window"
),
details={**stats, "recent_failures": recent_failures},
)
publisher = await get_campaign_event_publisher()
await publisher.publish_circuit_breaker_tripped(
@ -275,13 +373,16 @@ class CircuitBreaker:
async def reset(self, campaign_id: int) -> bool:
"""Reset the circuit breaker state for a campaign.
Called when a campaign is resumed to give it a clean slate.
Called when a campaign is resumed to give it a clean slate. Also clears
the recent-failures list so log entries from the next trip reference
only post-resume failures.
"""
redis_client = await self._get_redis()
fail_key, succ_key = self._keys(campaign_id)
recent_key = self._recent_failures_key(campaign_id)
try:
await redis_client.delete(fail_key, succ_key)
await redis_client.delete(fail_key, succ_key, recent_key)
logger.info(f"Circuit breaker reset for campaign {campaign_id}")
return True
except Exception as e:

View file

@ -97,6 +97,12 @@ def register_event_handlers(
"initial_response_triggered": False,
}
async def queue_initial_llm_context():
# Queue LLMContextFrame after the VoicemailDetector since the detector
# gates LLMContextFrames until voicemail detection completes. We also
# don't want to trigger the Voicemail LLM with this initial frame.
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def maybe_trigger_initial_response():
"""Start the conversation after both pipeline_started and client_connected events.
@ -185,7 +191,7 @@ def register_event_handlers(
f"Failed to fetch audio greeting {greeting_value}, "
"falling back to LLM generation"
)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await queue_initial_llm_context()
else:
logger.debug("Playing text greeting via TTS")
# append_to_context=True so the assistant aggregator commits
@ -198,7 +204,7 @@ def register_event_handlers(
logger.debug(
"Both pipeline_started and client_connected received - triggering initial LLM generation"
)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await queue_initial_llm_context()
@transport.event_handler("on_client_connected")
async def on_client_connected(_transport, _participant):
@ -235,7 +241,10 @@ def register_event_handlers(
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if workflow_run and workflow_run.campaign_id:
await circuit_breaker.record_and_evaluate(
campaign_id=workflow_run.campaign_id, is_failure=True
campaign_id=workflow_run.campaign_id,
is_failure=True,
workflow_run_id=workflow_run_id,
reason="pipeline_error",
)
asyncio.create_task(
_capture_call_event(

View file

@ -105,23 +105,53 @@ def build_realtime_pipeline(
assistant_context_aggregator,
pipeline_engine_callback_processor,
pipeline_metrics_aggregator,
voicemail_detector=None,
):
"""Build a pipeline for realtime (speech-to-speech) LLM services.
Realtime services (e.g. OpenAI Realtime, Gemini Live) handle STT+LLM+TTS
internally, so no separate STT or TTS processors are needed.
Args:
voicemail_detector: Optional VoicemailDetector. Placed *below* the
realtime LLM. This is asymmetric with the non-realtime layout
(where the detector sits between STT and the main user aggregator)
because the realtime LLM is both the source of TranscriptionFrame
(broadcast downstream) and the sink of LLMContextFrame (consumed
by _handle_context without forwarding). Placing the detector below
the realtime LLM means: downstream TranscriptionFrames reach the
classifier branch, UserStartedSpeakingFrame /
UserStoppedSpeakingFrame are forwarded through by the LLM, and the
main aggregator's LLMContextFrame is absorbed by the realtime LLM
and never leaks into the classifier (which would otherwise run a
voicemail completion on the workflow's main context).
The TTS gate and LLM gate are intentionally not used: the realtime
LLM reacts to audio directly, not to LLMContextFrames. On voicemail
detection we drop the call via end_call_with_reason; the detector's
ConversationGate also blocks downstream audio output until the call
ends.
"""
processors = [
transport.input(),
user_context_aggregator,
realtime_llm,
pipeline_engine_callback_processor,
transport.output(),
audio_buffer,
assistant_context_aggregator,
pipeline_metrics_aggregator,
]
if voicemail_detector:
logger.info("Adding native voicemail detector to realtime pipeline")
processors.append(voicemail_detector.detector())
processors.extend(
[
pipeline_engine_callback_processor,
transport.output(),
audio_buffer,
assistant_context_aggregator,
pipeline_metrics_aggregator,
]
)
return Pipeline(processors)

View file

@ -427,11 +427,13 @@ async def _run_pipeline(
# Configure turn strategies based on STT provider, model, and workflow configuration
if is_realtime:
# Realtime services have server-side VAD/turn detection.
# For stop strategy, lets rely on SmartTurnAnalyzer which is
# enabled by default
# Realtime services do server-side turn detection for response generation,
# but we still need a client-side stop strategy so the user aggregator emits
# UserStoppedSpeakingFrame. Without it, downstream consumers (e.g. voicemail
# detector) and Gemini Live's _finalize_pending flag never see a turn end.
user_turn_strategies = UserTurnStrategies(
start=[VADUserTurnStartStrategy()], stop=[]
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy()],
)
# Lets not start the pipeline as muted for Realtime
@ -521,7 +523,6 @@ async def _run_pipeline(
async def on_user_turn_started(aggregator, strategy):
user_idle_handler.reset()
# Voicemail detection and recording router are not supported in realtime mode
voicemail_detector = None
recording_router = None
@ -533,58 +534,61 @@ async def _run_pipeline(
)
engine.set_fetch_recording_audio(fetch_audio)
if not is_realtime:
# Create voicemail detector if enabled in workflow configurations
voicemail_config = (workflow.workflow_configurations or {}).get(
"voicemail_detection", {}
# Voicemail detection works in both modes. In realtime mode the detector sits
# after the realtime LLM and consumes the TranscriptionFrames it broadcasts;
# the LLM gate / TTS gate are not used (the realtime LLM responds to audio
# directly, not LLMContextFrames), so on detection we rely on
# end_call_with_reason to drop the call.
voicemail_config = (workflow.workflow_configurations or {}).get(
"voicemail_detection", {}
)
if voicemail_config.get("enabled", False):
logger.info(f"Voicemail detection enabled for workflow run {workflow_run_id}")
# Create a separate LLM instance for the voicemail sub-pipeline
# (can't share with main pipeline as it would mess up frame linking)
if voicemail_config.get("use_workflow_llm", True):
voicemail_llm = create_llm_service(user_config)
else:
voicemail_llm = create_llm_service_from_provider(
provider=voicemail_config.get("provider", "openai"),
model=voicemail_config.get("model", "gpt-4.1"),
api_key=voicemail_config.get("api_key", ""),
)
long_speech_timeout = voicemail_config.get("long_speech_timeout", 8.0)
custom_system_prompt = voicemail_config.get("system_prompt") or None
voicemail_detector = VoicemailDetector(
llm=voicemail_llm,
long_speech_timeout=long_speech_timeout,
custom_system_prompt=custom_system_prompt,
)
if voicemail_config.get("enabled", False):
logger.info(
f"Voicemail detection enabled for workflow run {workflow_run_id}"
)
# Create a separate LLM instance for the voicemail sub-pipeline
# (can't share with main pipeline as it would mess up frame linking)
if voicemail_config.get("use_workflow_llm", True):
voicemail_llm = create_llm_service(user_config)
else:
voicemail_llm = create_llm_service_from_provider(
provider=voicemail_config.get("provider", "openai"),
model=voicemail_config.get("model", "gpt-4.1"),
api_key=voicemail_config.get("api_key", ""),
)
long_speech_timeout = voicemail_config.get("long_speech_timeout", 8.0)
custom_system_prompt = voicemail_config.get("system_prompt") or None
voicemail_detector = VoicemailDetector(
llm=voicemail_llm,
long_speech_timeout=long_speech_timeout,
custom_system_prompt=custom_system_prompt,
# Register event handler to end task when voicemail is detected
@voicemail_detector.event_handler("on_voicemail_detected")
async def _on_voicemail_detected(_processor):
logger.info(f"Voicemail detected for workflow run {workflow_run_id}")
await engine.end_call_with_reason(
reason=EndTaskReason.VOICEMAIL_DETECTED.value,
abort_immediately=True,
)
# Register event handler to end task when voicemail is detected
@voicemail_detector.event_handler("on_voicemail_detected")
async def _on_voicemail_detected(_processor):
logger.info(f"Voicemail detected for workflow run {workflow_run_id}")
await engine.end_call_with_reason(
reason=EndTaskReason.VOICEMAIL_DETECTED.value,
abort_immediately=True,
)
# Create recording router if workflow has active recordings
if has_recordings:
recording_router = RecordingRouterProcessor(
audio_sample_rate=audio_config.pipeline_sample_rate,
fetch_recording_audio=fetch_audio,
)
# Warm the recording cache in the background so audio is ready
# before the first playback request.
asyncio.create_task(
warm_recording_cache(
organization_id=workflow.organization_id,
pipeline_sample_rate=audio_config.pipeline_sample_rate,
)
# Recording router is only meaningful in non-realtime mode (it routes between
# pre-recorded audio playback and dynamic TTS; realtime LLMs produce audio
# directly).
if not is_realtime and has_recordings:
recording_router = RecordingRouterProcessor(
audio_sample_rate=audio_config.pipeline_sample_rate,
fetch_recording_audio=fetch_audio,
)
# Warm the recording cache in the background so audio is ready
# before the first playback request.
asyncio.create_task(
warm_recording_cache(
organization_id=workflow.organization_id,
pipeline_sample_rate=audio_config.pipeline_sample_rate,
)
)
# Build the pipeline
if is_realtime:
@ -596,6 +600,7 @@ async def _run_pipeline(
assistant_context_aggregator,
pipeline_engine_callback_processor,
pipeline_metrics_aggregator,
voicemail_detector=voicemail_detector,
)
else:
pipeline = build_pipeline(

View file

@ -139,6 +139,7 @@ class TelnyxProvider(TelephonyProvider):
status="initiated",
caller_number=from_number,
provider_metadata={
"call_id": call_control_id,
"call_control_id": call_control_id,
"call_leg_id": call_leg_id,
"call_session_id": call_session_id,
@ -321,6 +322,15 @@ class TelnyxProvider(TelephonyProvider):
},
)
except WebSocketDisconnect as e:
# Telnyx opens the WebSocket during `bridging` (pre-answer) but only
# sends the `start` event on `call.answered`. If the call ends before
# answer (no-answer timeout, busy, declined), Telnyx closes the
# socket abruptly — surface this as an expected end-of-call.
logger.info(
f"[run {workflow_run_id}] Telnyx WebSocket closed before stream start "
f"(call ended pre-answer): code={e.code}, reason={e.reason!r}"
)
except Exception as e:
logger.error(f"Error in Telnyx WebSocket handler: {e}")
raise

View file

@ -140,7 +140,8 @@ class VonageProvider(TelephonyProvider):
status=response_data.get("status", "started"),
caller_number=from_number,
provider_metadata={
"call_uuid": response_data["uuid"]
"call_id": response_data["uuid"],
"call_uuid": response_data["uuid"],
}, # Vonage needs UUID persisted for WebSocket
raw_response=response_data,
)

View file

@ -179,9 +179,12 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
is_failure = status.status in ("error", "failed")
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=status.status in ("error", "failed"),
is_failure=is_failure,
workflow_run_id=workflow_run_id if is_failure else None,
reason=status.status if is_failure else None,
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:

View file

@ -8,7 +8,10 @@ from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatc
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.campaign.errors import ConcurrentSlotAcquisitionError
from api.services.campaign.errors import (
ConcurrentSlotAcquisitionError,
PhoneNumberPoolExhaustedError,
)
from api.services.campaign.source_sync_factory import get_sync_service
@ -80,6 +83,13 @@ async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
source_sync_status="failed",
source_sync_error=str(e),
)
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="source_sync_failed",
message=f"Source sync failed: {e}",
details={"error": str(e)},
)
raise
@ -137,6 +147,39 @@ async def process_campaign_batch(
# Update campaign state to failed
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="batch_failed",
message=f"Concurrent slot acquisition timeout: {e}",
details={"error": str(e), "reason": "concurrent_slot_timeout"},
)
raise
except PhoneNumberPoolExhaustedError as e:
logger.warning(f"Phone number pool exhausted for campaign {campaign_id}: {e}")
publisher = await get_campaign_event_publisher()
await publisher.publish_batch_failed(
campaign_id=campaign_id,
error=f"Phone number pool exhausted: {e}",
processed_count=0,
)
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="phone_number_pool_exhausted",
message=(
f"Phone number pool exhausted for org {e.organization_id}: "
"no free from_number available to dispatch outbound calls"
),
details={
"error": str(e),
"organization_id": e.organization_id,
},
)
raise
except Exception as e:
@ -152,4 +195,11 @@ async def process_campaign_batch(
# Update campaign state to failed
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="batch_failed",
message=f"Batch processing failed: {e}",
details={"error": str(e)},
)
raise

View file

@ -0,0 +1,87 @@
"""
Tests for api.tasks.campaign_tasks failure handling.
Specifically: each kind of failure that pauses or fails a campaign should
write a specific, identifiable entry into the campaign log so operators
can tell at a glance why a campaign stopped.
"""
from unittest.mock import AsyncMock, patch
import pytest
from api.services.campaign.errors import (
ConcurrentSlotAcquisitionError,
PhoneNumberPoolExhaustedError,
)
from api.tasks.campaign_tasks import process_campaign_batch
class TestProcessCampaignBatchFailureLogs:
"""``process_campaign_batch`` should log a *specific* event for each
distinct failure mode, not collapse them all into a generic
``batch_failed`` entry."""
@pytest.mark.asyncio
async def test_phone_number_pool_exhausted_logs_specific_event(self):
"""When PhoneNumberPoolExhaustedError propagates from process_batch,
the campaign log entry should use event='phone_number_pool_exhausted'
with a clear message not the generic 'batch_failed' bucket."""
with (
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
patch("api.tasks.campaign_tasks.db_client") as mock_db,
patch(
"api.tasks.campaign_tasks.get_campaign_event_publisher"
) as mock_get_pub,
):
mock_disp.process_batch = AsyncMock(
side_effect=PhoneNumberPoolExhaustedError(organization_id=7)
)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_pub = AsyncMock()
mock_get_pub.return_value = mock_pub
with pytest.raises(PhoneNumberPoolExhaustedError):
await process_campaign_batch({}, campaign_id=42)
mock_db.update_campaign.assert_called_once_with(
campaign_id=42, state="failed"
)
mock_db.append_campaign_log.assert_called_once()
kwargs = mock_db.append_campaign_log.call_args.kwargs
assert kwargs["campaign_id"] == 42
assert kwargs["event"] == "phone_number_pool_exhausted"
assert kwargs["level"] == "error"
assert "phone number" in kwargs["message"].lower()
assert kwargs["details"]["organization_id"] == 7
@pytest.mark.asyncio
async def test_concurrent_slot_timeout_still_logs_specific_event(self):
"""Regression guard: the existing ConcurrentSlotAcquisitionError branch
should keep logging its specific reason."""
with (
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
patch("api.tasks.campaign_tasks.db_client") as mock_db,
patch(
"api.tasks.campaign_tasks.get_campaign_event_publisher"
) as mock_get_pub,
):
mock_disp.process_batch = AsyncMock(
side_effect=ConcurrentSlotAcquisitionError(
organization_id=7, campaign_id=42, wait_time=30.0
)
)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_pub = AsyncMock()
mock_get_pub.return_value = mock_pub
with pytest.raises(ConcurrentSlotAcquisitionError):
await process_campaign_batch({}, campaign_id=42)
mock_db.append_campaign_log.assert_called_once()
kwargs = mock_db.append_campaign_log.call_args.kwargs
assert kwargs["event"] == "batch_failed"
assert kwargs["details"]["reason"] == "concurrent_slot_timeout"

View file

@ -198,7 +198,9 @@ class TestCircuitBreakerReset:
result = await cb.reset(campaign_id=42)
assert result is True
mock_redis.delete.assert_called_once_with("cb_failures:42", "cb_successes:42")
mock_redis.delete.assert_called_once_with(
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
)
@pytest.mark.asyncio
async def test_reset_on_redis_error(self):
@ -253,6 +255,7 @@ class TestRecordAndEvaluate:
):
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_publisher = AsyncMock()
mock_get_publisher.return_value = mock_publisher
@ -352,6 +355,206 @@ class TestRecordAndEvaluate:
await cb.record_and_evaluate(campaign_id=42, is_failure=True)
# =============================================================================
# Tests for recent-failures tracking (workflow_run_id + reason)
# =============================================================================
class TestCircuitBreakerRecentFailures:
"""When a call fails, the circuit breaker should remember the workflow_run_id
and reason in a capped Redis list, and surface those entries in the campaign
log entry written when the breaker trips."""
@pytest.mark.asyncio
async def test_failure_pushes_recent_failure_entry(self):
"""is_failure=True with run id + reason should push to recent-failures list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.append_campaign_log = AsyncMock()
cb.record_call_outcome = AsyncMock(return_value=(False, None))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=[])
await cb.record_and_evaluate(
campaign_id=42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
cb._push_recent_failure.assert_called_once_with(
campaign_id=42, workflow_run_id=100, reason="failed"
)
@pytest.mark.asyncio
async def test_success_does_not_push_recent_failure(self):
"""is_failure=False must not push to the recent-failures list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
cb.record_call_outcome = AsyncMock(return_value=(False, None))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=[])
await cb.record_and_evaluate(
campaign_id=42,
is_failure=False,
workflow_run_id=100,
reason=None,
)
cb._push_recent_failure.assert_not_called()
@pytest.mark.asyncio
async def test_trip_log_includes_recent_failures_in_details(self):
"""When the breaker trips, the campaign log entry's details should include
recent_failures fetched from the Redis list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
stats = {
"failure_rate": 0.6,
"failure_count": 6,
"success_count": 4,
"threshold": 0.5,
"window_seconds": 120,
}
recent = [
{"workflow_run_id": 100, "reason": "failed", "ts": 1700000010.0},
{"workflow_run_id": 99, "reason": "error", "ts": 1700000000.0},
]
with (
patch("api.services.campaign.circuit_breaker.db_client") as mock_db,
patch(
"api.services.campaign.circuit_breaker.get_campaign_event_publisher"
) as mock_get_publisher,
):
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_publisher = AsyncMock()
mock_get_publisher.return_value = mock_publisher
cb.record_call_outcome = AsyncMock(return_value=(True, stats))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=recent)
await cb.record_and_evaluate(
campaign_id=42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
mock_db.append_campaign_log.assert_called_once()
kwargs = mock_db.append_campaign_log.call_args.kwargs
assert kwargs["campaign_id"] == 42
assert kwargs["event"] == "circuit_breaker_tripped"
assert kwargs["details"]["recent_failures"] == recent
@pytest.mark.asyncio
async def test_push_recent_failure_uses_lpush_and_ltrim(self):
"""_push_recent_failure should LPUSH a JSON entry and LTRIM the list
to keep only the most recent N (default 20)."""
import json
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
mock_redis.lpush = AsyncMock(return_value=1)
mock_redis.ltrim = AsyncMock(return_value=True)
mock_redis.expire = AsyncMock(return_value=True)
cb.redis_client = mock_redis
await cb._push_recent_failure(
campaign_id=42, workflow_run_id=100, reason="failed"
)
# Verify the key used
mock_redis.lpush.assert_called_once()
push_args = mock_redis.lpush.call_args.args
assert push_args[0] == "cb_recent_failures:42"
# Verify the payload includes the run id + reason
entry = json.loads(push_args[1])
assert entry["workflow_run_id"] == 100
assert entry["reason"] == "failed"
assert "ts" in entry
# Verify the cap (LTRIM 0 19 keeps 20 entries)
mock_redis.ltrim.assert_called_once_with("cb_recent_failures:42", 0, 19)
@pytest.mark.asyncio
async def test_get_recent_failures_decodes_lrange(self):
"""_get_recent_failures should LRANGE the list and JSON-decode entries."""
import json
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
entries = [
json.dumps({"workflow_run_id": 100, "reason": "failed", "ts": 1.0}),
json.dumps({"workflow_run_id": 99, "reason": "error", "ts": 0.5}),
]
mock_redis.lrange = AsyncMock(return_value=entries)
cb.redis_client = mock_redis
result = await cb._get_recent_failures(campaign_id=42)
mock_redis.lrange.assert_called_once_with("cb_recent_failures:42", 0, -1)
assert result == [
{"workflow_run_id": 100, "reason": "failed", "ts": 1.0},
{"workflow_run_id": 99, "reason": "error", "ts": 0.5},
]
@pytest.mark.asyncio
async def test_reset_clears_recent_failures_key(self):
"""reset() must also delete cb_recent_failures:{campaign_id}."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
mock_redis.delete = AsyncMock(return_value=3)
cb.redis_client = mock_redis
await cb.reset(campaign_id=42)
mock_redis.delete.assert_called_once_with(
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
)
# =============================================================================
# Integration tests: _process_status_update calls circuit_breaker
# =============================================================================
@ -405,7 +608,12 @@ class TestProcessStatusUpdateCircuitBreaker:
await _process_status_update(100, status)
mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=True)
mock_cb.record_and_evaluate.assert_called_once_with(
42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
@pytest.mark.asyncio
async def test_success_status_calls_record_and_evaluate(self):

View file

@ -720,9 +720,9 @@ class TestCustomToolManagerUnit:
@pytest.mark.asyncio
async def test_get_tool_schemas_returns_correct_format(self):
"""Test that get_tool_schemas returns FunctionSchema objects."""
# Create a mock engine
from pipecat.adapters.schemas.function_schema import FunctionSchema
# Create a mock engine
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager

File diff suppressed because one or more lines are too long

View file

@ -27,48 +27,138 @@ Step 4: Copy the generated embed code and paste it into your web page to test yo
| Mode | What it renders | When to use |
| --------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Floating Widget** | A circular call button anchored to a corner of the page. | You want a turn-key chat-bubble experience that doesn't disturb your existing layout. |
| **Floating Widget** | A pill-shaped CTA button anchored to a corner of the page. | You want a turn-key chat-bubble experience that doesn't disturb your existing layout. |
| **Inline Component** | A panel rendered inside a `<div id="dograh-inline-container">` that you place in your page. | You want the agent embedded in a specific section (landing-page hero, support tab, etc.). |
| **Headless** | No UI. Only the audio pipeline plus a JavaScript API on `window.DograhWidget`. | You want full control over the UI — your own buttons, design system, framework state, animations. |
## Headless mode
## Prerequisites
These apply to all three modes:
- Serve your page over **HTTPS** or from `http://localhost`. Browsers refuse microphone access on plain HTTP origins or `file://`.
- If you set **Allowed Domains** in the dashboard, include your test origin (e.g. `localhost`) — otherwise the widget's config and signaling requests are rejected. Leave the list empty to allow all domains.
- The embed snippet you copy from the dashboard is a single `<script>` tag that loads `dograh-widget.js` **asynchronously**. The widget auto-initializes once it loads and exposes `window.DograhWidget`. Code that registers callbacks must wait for the widget to be available.
## Floating Widget
![Floating widget shown in the corner of a host page](../images/floating-widget-example.png)
Renders a pill-shaped button (microphone icon + text) anchored to a corner of the page. Clicking it starts a call; clicking again ends it. The button auto-updates its label and color across the call lifecycle: configured text → "Connecting…" → "End Call" → "Retry" on failure.
Configure **Button Text**, **Button Color**, and **Position** (top/bottom + left/right) from the dashboard.
The host page writes no JavaScript — pasting the embed snippet is the entire integration. If you want to subscribe to call lifecycle events (e.g. analytics), see [Lifecycle callbacks](#lifecycle-callbacks-all-modes) below
## Inline Component
![Inline widget rendered inside a page section](../images/inline-widget-example.png)
Renders a panel (status icon + status text + CTA button) inside a `<div>` you place in your page. Status changes update the panel in place.
Configure **Button Text**, **Button Color**, and **Call to Action Text** from the dashboard.
### Plain HTML
Place a container `<div>` where you want the widget to render. The widget auto-attaches to it.
```html
<!-- Paste the dograh embed snippet from the dashboard somewhere on the page -->
<div id="dograh-inline-container"></div>
```
### React
Because React mounts after the widget script may have already loaded, integrate via `initInline` on first mount and `refresh` on remount. Poll for `window.DograhWidget` to handle the async script load.
```tsx
import { useEffect } from 'react';
declare global {
interface Window {
DograhWidget?: {
initInline: (options: { container: HTMLElement }) => void;
refresh: () => void;
getState: () => { isInitialized: boolean };
};
}
}
export function Assistant() {
useEffect(() => {
let retries = 0;
const tryInit = () => {
const container = document.getElementById('dograh-inline-container');
if (window.DograhWidget && container) {
const { isInitialized } = window.DograhWidget.getState();
if (isInitialized) window.DograhWidget.refresh();
else window.DograhWidget.initInline({ container });
} else if (retries++ < 50) {
setTimeout(tryInit, 100);
}
};
tryInit();
}, []);
return <div id="dograh-inline-container" />;
}
```
## Headless Mode
![Headless widget driven by host-page UI](../images/headless-widget-example.png)
In Headless mode the widget injects no UI of its own. You render whatever buttons, banners, or in-call indicators you want, and call the JavaScript API to start and end calls.
### JavaScript API
| Method / Callback | Description |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `window.DograhWidget.start()` | Begin a voice call. Must be called from inside a user-gesture handler (e.g. `click`) so the browser grants microphone access. |
| `window.DograhWidget.end()` | End the active call. |
| `window.DograhWidget.onStatusChange(cb)` | Fires on every status change. Values: `idle`, `connecting`, `connected`, `failed`. |
| `window.DograhWidget.onCallStart(cb)` | Fires once the call is connected. |
| `window.DograhWidget.onCallEnd(cb)` | Fires when the call ends. |
| `window.DograhWidget.onError(cb)` | Fires on any error (mic permission denied, server error, etc.). |
| `window.DograhWidget.getState()` | Returns the current widget state, including `connectionStatus`. |
| Method / Callback | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `window.DograhWidget.start()` | Begin a voice call. Must be called from inside a user-gesture handler (e.g. `click`) so the browser grants microphone access. |
| `window.DograhWidget.end()` | End the active call. |
| `window.DograhWidget.onCallStart(cb)` | Fires when `start()` is invoked (status `connecting`). No payload. |
| `window.DograhWidget.onCallConnected(cb)` | Fires when the WebRTC connection is established. Payload: `{ agentId, workflowRunId, token }`. |
| `window.DograhWidget.onCallDisconnected(cb)` | Fires only if the call had connected, when teardown runs. Payload: `{ agentId, workflowRunId, token, durationSeconds }`. |
| `window.DograhWidget.onCallEnd(cb)` | Fires whenever the call session is torn down (including failed-to-connect attempts). No payload. |
| `window.DograhWidget.onStatusChange(cb)` | Fires on every status change. Callback receives `(status, text, subtext)`. Status values: `idle`, `connecting`, `connected`, `failed`. |
| `window.DograhWidget.onError(cb)` | Fires on errors (mic permission denied, server error, etc.). Callback receives an `Error` object. |
### Recommended pattern
All `on*` setters are single-listener — calling the same one again replaces the previous handler.
Mirror the call status into a state variable that you own, then render whatever UI you like from it.
<Note>
**About timing.** The widget script loads asynchronously, so `window.DograhWidget` may not exist at the moment your inline `<script>` first runs. The examples below assume `window.DograhWidget` is already available when registration runs. To guarantee that:
#### Vanilla JS
- **Vanilla JS:** wrap your registration code in `window.addEventListener('load', () => { /* register here */ })`.
- **React:** inside `useEffect`, register immediately if `document.readyState === 'complete'`, otherwise add a one-time `window.load` listener that registers on fire.
- **Click handlers** that call `start()` / `end()` don't need a guard — by the time a user clicks, the widget has long since loaded.
</Note>
### Vanilla JS
```html
<button id="talk-btn">Talk to AI</button>
<script>
let callStatus = 'idle';
const btn = document.getElementById('talk-btn');
window.DograhWidget?.onStatusChange((status) => {
callStatus = status;
document.getElementById('talk-btn').textContent =
status === 'connected' ? 'End Call'
: status === 'connecting' ? 'Connecting…'
: status === 'failed' ? 'Retry'
function render() {
btn.textContent =
callStatus === 'connected' ? 'End Call'
: callStatus === 'connecting' ? 'Connecting…'
: callStatus === 'failed' ? 'Retry'
: 'Talk to AI';
}
window.DograhWidget.onStatusChange((status) => {
callStatus = status;
render();
});
document.getElementById('talk-btn').addEventListener('click', () => {
window.DograhWidget.onError((err) => {
console.error('Dograh error:', err.message);
});
btn.addEventListener('click', () => {
if (callStatus === 'connected' || callStatus === 'connecting') {
window.DograhWidget.end();
} else {
@ -78,14 +168,30 @@ Mirror the call status into a state variable that you own, then render whatever
</script>
```
#### React
### React + TypeScript
```tsx
function TalkButton() {
const [status, setStatus] = useState('idle');
import { useEffect, useState } from 'react';
type CallStatus = 'idle' | 'connecting' | 'connected' | 'failed';
declare global {
interface Window {
DograhWidget: {
start: () => void;
end: () => void;
onStatusChange: (cb: (status: CallStatus, text?: string, subtext?: string) => void) => void;
onError: (cb: (err: Error) => void) => void;
};
}
}
export function TalkButton() {
const [status, setStatus] = useState<CallStatus>('idle');
useEffect(() => {
window.DograhWidget?.onStatusChange(setStatus);
window.DograhWidget.onStatusChange((s) => setStatus(s));
window.DograhWidget.onError((err) => console.error('Dograh error:', err.message));
}, []);
const isLive = status === 'connected' || status === 'connecting';
@ -102,3 +208,19 @@ function TalkButton() {
<Note>
`start()` must run inside a real user-gesture handler (`click`, `touchend`, etc.). Browsers refuse to grant microphone access to scripts that request it outside of one — calling `start()` from a `setTimeout` or on page load will fail with a permission error.
</Note>
## Lifecycle callbacks (all modes)
The `on*` callbacks in the [Headless JavaScript API](#javascript-api) work in **all three embed modes**, not just Headless. Use them for analytics or to trigger UI in the host page even when the widget is rendering its own UI (Floating or Inline).
```js
window.DograhWidget.onCallConnected(({ agentId, workflowRunId }) => {
analytics.track('voice_call_started', { agentId, workflowRunId });
});
window.DograhWidget.onCallDisconnected(({ workflowRunId, durationSeconds }) => {
analytics.track('voice_call_ended', { workflowRunId, durationSeconds });
});
```
`onCallConnected` and `onCallDisconnected` only fire when the call actually establishes a media connection — failed-to-connect attempts (e.g. denied mic, network failure) don't trigger them, so analytics stay clean.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 465 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Before After
Before After

@ -1 +1 @@
Subproject commit 95f03dc647d60f505057db033161f609d9edf79d
Subproject commit e8f59a6a58977f4f5598e8de7bfbbf9b6d474eb8

View file

@ -1,5 +1,7 @@
#!/bin/sh -e
#!/usr/bin/env bash
set -euo pipefail
ruff check api --select I --select F401 --fix
ruff format api
(cd ui && npm run fix-lint)

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.YApLaGcbbM
# timestamp: 2026-05-04T09:31:31+00:00
# filename: dograh-openapi-XXXXXX.json.r8rR0xozEB
# timestamp: 2026-05-06T12:17:39+00:00
from __future__ import annotations

View file

@ -1,7 +1,7 @@
"use client";
import { format } from 'date-fns';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, ArrowLeft, CalendarIcon, Check, Clock, Download, Info, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -381,6 +381,38 @@ export default function CampaignDetailPage() {
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
// Newest entries first. The backend appends chronologically; the UI is more
// useful when the most recent failure / pause is at the top.
const sortedLogs = (campaign?.logs ?? []).slice().reverse();
const getLogIcon = (level: string) => {
switch (level) {
case 'error':
return <AlertCircle className="h-4 w-4 text-destructive" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
const getLogBadgeVariant = (level: string): 'destructive' | 'secondary' | 'outline' => {
switch (level) {
case 'error':
return 'destructive';
case 'warning':
return 'outline';
default:
return 'secondary';
}
};
const formatLogTimestamp = (ts: string) => {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleString();
};
// Render action button based on state
const renderActionButton = () => {
if (!campaign || isExecutingAction) return null;
@ -796,6 +828,56 @@ export default function CampaignDetailPage() {
</CardContent>
</Card>
{/* Activity Log */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>
Recent state transitions and failures. Newest first.
</CardDescription>
</CardHeader>
<CardContent>
{sortedLogs.length === 0 ? (
<p className="text-sm text-muted-foreground">No events recorded yet.</p>
) : (
<ul className="space-y-3">
{sortedLogs.map((entry, idx) => (
<li
key={`${entry.ts}-${idx}`}
className="flex gap-3 border-b last:border-b-0 pb-3 last:pb-0"
>
<div className="mt-0.5">{getLogIcon(entry.level)}</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getLogBadgeVariant(entry.level)} className="text-xs">
{entry.level}
</Badge>
<code className="text-xs text-muted-foreground">
{entry.event}
</code>
<span className="text-xs text-muted-foreground">
{formatLogTimestamp(entry.ts)}
</span>
</div>
<p className="text-sm mt-1 break-words">{entry.message}</p>
{entry.details && Object.keys(entry.details).length > 0 && (
<details className="mt-1.5">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Details
</summary>
<pre className="mt-1.5 text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap break-words">
{JSON.stringify(entry.details, null, 2)}
</pre>
</details>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Workflow Runs */}
<CampaignRuns
campaignId={campaignId}

View file

@ -8,9 +8,10 @@ import { useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
initiateCallApiV1TelephonyInitiateCallPost
initiateCallApiV1TelephonyInitiateCallPost,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -22,6 +23,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
@ -47,6 +56,8 @@ export const PhoneCallDialog = ({
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
// Check telephony configuration when dialog opens
useEffect(() => {
@ -55,16 +66,25 @@ export const PhoneCallDialog = ({
setCheckingConfig(true);
try {
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({});
const configResponse = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari && !configResponse.data?.telnyx && !configResponse.data?.plivo)) {
const configurations = configResponse.data?.configurations ?? [];
if (configResponse.error || configurations.length === 0) {
setNeedsConfiguration(true);
setTelephonyConfigs([]);
setSelectedConfigId("");
} else {
setNeedsConfiguration(false);
setTelephonyConfigs(configurations);
const defaultConfig =
configurations.find((c) => c.is_default_outbound) ?? configurations[0];
setSelectedConfigId(String(defaultConfig.id));
}
} catch (err) {
console.error("Failed to check telephony config:", err);
setNeedsConfiguration(false);
setTelephonyConfigs([]);
setSelectedConfigId("");
} finally {
setCheckingConfig(false);
}
@ -80,6 +100,8 @@ export const PhoneCallDialog = ({
setCallSuccessMsg(null);
setCallLoading(false);
setNeedsConfiguration(null);
setTelephonyConfigs([]);
setSelectedConfigId("");
}
}, [open]);
@ -124,7 +146,8 @@ export const PhoneCallDialog = ({
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
workflow_id: workflowId,
phone_number: phoneNumber
phone_number: phoneNumber,
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
},
});
@ -189,6 +212,24 @@ export const PhoneCallDialog = ({
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
{telephonyConfigs.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="telephony-config">Telephony configuration</Label>
<Select value={selectedConfigId} onValueChange={setSelectedConfigId}>
<SelectTrigger id="telephony-config" className="w-full">
<SelectValue placeholder="Select a configuration" />
</SelectTrigger>
<SelectContent>
{telephonyConfigs.map((config) => (
<SelectItem key={config.id} value={String(config.id)}>
{config.name} ({config.provider})
{config.is_default_outbound ? " — default" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{sipMode ? (
<Input
value={phoneNumber}

File diff suppressed because one or more lines are too long

View file

@ -346,6 +346,39 @@ export type CampaignDefaultsResponse = {
last_campaign_settings?: LastCampaignSettingsResponse | null;
};
/**
* CampaignLogEntryResponse
*
* A single timestamped entry from the campaign's append-only log.
*
* Surfaced in the UI so operators can see why a campaign moved to
* paused / failed without digging through server logs.
*/
export type CampaignLogEntryResponse = {
/**
* Ts
*/
ts: string;
/**
* Level
*/
level: string;
/**
* Event
*/
event: string;
/**
* Message
*/
message: string;
/**
* Details
*/
details?: {
[key: string]: unknown;
} | null;
};
/**
* CampaignProgressResponse
*/
@ -481,6 +514,10 @@ export type CampaignResponse = {
* Telephony Configuration Name
*/
telephony_configuration_name?: string | null;
/**
* Logs
*/
logs?: Array<CampaignLogEntryResponse>;
};
/**