diff --git a/src/bot/core/session_handlers.py b/src/bot/core/session_handlers.py
index 8fa2040..361b0ed 100644
--- a/src/bot/core/session_handlers.py
+++ b/src/bot/core/session_handlers.py
@@ -74,7 +74,7 @@ def create_transcript_handler(bot_session: "BotSessionState", transcript_manager

 def create_client_message_handler(bot_session: "BotSessionState", rtvi, task, transcript_manager: "TranscriptManager"):
-    from src.bot.services.image_upload import handle_image_upload, handle_image_batch_upload, handle_load_image_from_url
+    from src.bot.services.image_upload import handle_image_upload, handle_image_upload_from_url, handle_image_batch_upload, handle_load_image_from_url
     async def on_client_message(rtvi_proc, message):
         try:
             msg_type = getattr(message, 'type', None)
@@ -90,10 +90,16 @@ def create_client_message_handler(bot_session: "BotSessionState", rtvi, task, tr

             if msg_type == "user_image_upload":
                 data = msg_data if isinstance(msg_data, dict) else {}
+                s3_url = data.get("s3_url")
                 image_data = data.get("image")
                 mime_type = data.get("mime_type", "image/jpeg")

-                if image_data:
+                if s3_url:
+                    await handle_image_upload_from_url(
+                        s3_url, mime_type, bot_session,
+                        rtvi, task, transcript_manager
+                    )
+                elif image_data:
                     await handle_image_upload(
                         image_data, mime_type, bot_session,
                         rtvi, task, transcript_manager, source="rtvi"
@@ -128,8 +134,8 @@ def create_cleanup_if_no_user(bot_session: "BotSessionState", session_state, tas
         await asyncio.sleep(constants.NO_USER_TIMEOUT)

         if not bot_session.user_joined:
-            logger.warning(f"NO_USER_TIMEOUT: {bot_session.get_session_info()} timeout={constants.NO_USER_TIMEOUT}s action=closing_room")
-            logger.info(f"CLEANUP_TRIGGER: session_id={bot_session.session_id} reason=no_user_timeout")
+            logger.warning(f"NO_USER_TIMEOUT: {bot_session.get_session_info()} transport={bot_session.transport_type} timeout={constants.NO_USER_TIMEOUT}s action=closing_room")
+            logger.info(f"CLEANUP_TRIGGER: session_id={bot_session.session_id} transport={bot_session.transport_type} reason=no_user_timeout")

             try:
                 await session_repo.save_session(
@@ -148,28 +154,23 @@ def create_cleanup_if_no_user(bot_session: "BotSessionState", session_state, tas
     return cleanup_if_no_user

-def create_participant_joined_handler(bot_session: "BotSessionState", task):
-    async def on_participant_joined(transport, participant):
+def create_client_connected_handler(bot_session: "BotSessionState", task):
+    """Handle SmallWebRTC client connection (replaces on_participant_joined)."""
+    async def on_client_connected(transport, client):
         join_time = time.time()
-        participant_id = participant['id']
-        participant_name = participant.get("info", {}).get("userName", "Guest")
-        participant_type = "maya" if participant_name == "Maya Global Agent Bot" else "user"
-
-        bot_session.add_participant(participant_id, participant_name, participant_type, join_time)
-
-        if participant_type == "maya":
-            bot_session.bot_joined = True
-        else:
-            bot_session.user_joined = True
-            elapsed = join_time - bot_session.session_start_time
-            logger.info(f"USER_JOINED: session_id={bot_session.session_id} participant_id={participant_id} elapsed={elapsed:.1f}s")
-
-            if bot_session.bot_idle:
-                bot_session.bot_idle = False
-                await task.queue_frames([LLMRunFrame()])
-
-            bot_session.cancel_timeout_task()
-    return on_participant_joined
+        participant_id = client.pc_id if hasattr(client, 'pc_id') else "user"
+
+        bot_session.add_participant(participant_id, "User", "user", join_time)
+        bot_session.user_joined = True
+        elapsed = join_time - bot_session.session_start_time
+        logger.info(f"USER_JOINED: session_id={bot_session.session_id} participant_id={participant_id} elapsed={elapsed:.1f}s transport={bot_session.transport_type}")
+
+        if bot_session.bot_idle:
+            bot_session.bot_idle = False
+            await task.queue_frames([LLMRunFrame()])
+
+        bot_session.cancel_timeout_task()
+    return on_client_connected

 def create_idle_timeout_handler(bot_session: "BotSessionState"):
@@ -203,33 +204,77 @@ def create_app_review_trigger(bot_session: "BotSessionState", rtvi):
     return trigger_app_review_if_eligible

-def create_first_participant_joined_handler(bot_session: "BotSessionState", rtvi, task, audio_buffer, chunk_state: "ChunkState"):
-    async def on_first_participant_joined(transport, participant):
+def create_first_client_connected_handler(bot_session: "BotSessionState", rtvi, task, audio_buffer, chunk_state: "ChunkState"):
+    """Handle first SmallWebRTC client connection (replaces on_first_participant_joined)."""
+    async def on_first_client_connected(transport, client):
+        participant_id = client.pc_id if hasattr(client, 'pc_id') else "user"
+        participant = {'id': participant_id}
         await handle_first_participant_joined(
             participant, bot_session, transport, rtvi, task, audio_buffer, chunk_state
         )
+    return on_first_client_connected
+
+
+def create_client_disconnected_handler(bot_session: "BotSessionState", session_state, transcript_manager: "TranscriptManager", chunk_state: "ChunkState", task):
+    """Handle SmallWebRTC client disconnection — does full cleanup + cancel.
+    Combines old on_participant_left (save session) and on_client_disconnected (cancel task)."""
+    async def on_client_disconnected(transport, client):
+        participant_id = client.pc_id if hasattr(client, 'pc_id') else "user"
+        participant = {'id': participant_id}
+        await handle_participant_left(
+            participant, "disconnected", bot_session, session_state,
+            transcript_manager, chunk_state, task
+        )
+    return on_client_disconnected
+
+
+def create_daily_participant_joined_handler(bot_session: "BotSessionState", task):
+    """Handle Daily participant join event."""
+    async def on_participant_joined(transport, participant):
+        join_time = time.time()
+        participant_id = participant.get("id", "user")
+        user_name = participant.get("info", {}).get("userName", "User")
+
+        bot_session.add_participant(participant_id, user_name, "user", join_time)
+        bot_session.user_joined = True
+        elapsed = join_time - bot_session.session_start_time
+        logger.info(f"USER_JOINED: session_id={bot_session.session_id} participant_id={participant_id} name={user_name} elapsed={elapsed:.1f}s transport=daily")
+
+        if bot_session.bot_idle:
+            bot_session.bot_idle = False
+            await task.queue_frames([LLMRunFrame()])
+
+        bot_session.cancel_timeout_task()
+    return on_participant_joined
+
+
+def create_daily_first_participant_joined_handler(bot_session: "BotSessionState", transport, rtvi, task, audio_buffer, chunk_state: "ChunkState"):
+    """Handle Daily first participant join — starts recording and greeting."""
+    async def on_first_participant_joined(transport_ref, participant):
+        participant_id = participant.get("id", "user")
+        participant_dict = {'id': participant_id}
+
+        # Daily: capture participant transcription
+        transport.capture_participant_transcription(participant_id)
+
+        await handle_first_participant_joined(
+            participant_dict, bot_session, transport, rtvi, task, audio_buffer, chunk_state
+        )
     return on_first_participant_joined

-def create_participant_left_handler(bot_session: "BotSessionState", session_state, transcript_manager: "TranscriptManager", chunk_state: "ChunkState", task):
+def create_daily_participant_left_handler(bot_session: "BotSessionState", session_state, transcript_manager: "TranscriptManager", chunk_state: "ChunkState", task):
+    """Handle Daily participant left — does full cleanup + cancel."""
     async def on_participant_left(transport, participant, reason):
+        participant_id = participant.get("id", "user")
+        participant_dict = {'id': participant_id}
         await handle_participant_left(
-            participant, reason, bot_session, session_state,
+            participant_dict, reason, bot_session, session_state,
             transcript_manager, chunk_state, task
         )
     return on_participant_left

-def create_client_disconnected_handler(bot_session: "BotSessionState", task):
-    """Immediate termination when client disconnects (e.g. network drop, browser closed).
-    Per Pipecat docs: use for user disconnections; cleanup runs when runner completes."""
-    async def on_client_disconnected(transport, client):
-        session_id = bot_session.session_id or "unknown"
-        logger.info(f"CLIENT_DISCONNECTED: session_id={session_id} - terminating pipeline")
-        await task.cancel()
-    return on_client_disconnected
-
-
 async def handle_first_participant_joined(
     participant,
     bot_session: "BotSessionState",
@@ -242,7 +287,6 @@ async def handle_first_participant_joined(
     join_time = time.time()
     participant_id = participant['id']
     bot_session.current_user_id = participant_id
-    await transport.capture_participant_transcription(participant_id)

     if not bot_session.session_id:
         bot_session.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{participant_id}"
@@ -268,7 +312,7 @@ async def handle_first_participant_joined(
     # Saves ~50% of session writes (~390 writes/min with 500 concurrent sessions)
     # Trade-off: If pod crashes, active sessions won't be saved (~0.1% loss - acceptable)
     bot_session.session_created_at_start = False  # Will be created at end
-    logger.info(f"SESSION_STARTED_IN_MEMORY: session_id={session_id} device_id={device_id}")
+    logger.info(f"SESSION_STARTED_IN_MEMORY: session_id={session_id} device_id={device_id} transport={bot_session.transport_type}")

     async def send_session_info():
         await asyncio.sleep(0.5)
@@ -313,18 +357,15 @@ async def handle_participant_left(
     from src.bot.core.config import cleanup_session
     leave_time = time.time()
     participant_id = participant.get('id') if isinstance(participant, dict) else str(participant)
-    participant_name = participant.get("info", {}).get("userName", "Guest") if isinstance(participant, dict) else "Unknown"
-    participant_type = "maya" if participant_name == "Maya Global Agent Bot" else "user"
-
+
     session_id = bot_session.session_id or 'unknown'
     device_id = bot_session.device_id or 'common'
-
+
     participant_data = bot_session.get_participant(participant_id)
     duration = leave_time - participant_data.get("joined_at", leave_time)
-
-    if participant_type != "maya":
-        elapsed = leave_time - bot_session.session_start_time
-        logger.info(f"USER_LEFT: session_id={session_id} elapsed={elapsed:.1f}s duration={duration:.1f}s reason={reason}")
+
+    elapsed = leave_time - bot_session.session_start_time
+    logger.info(f"USER_LEFT: session_id={session_id} elapsed={elapsed:.1f}s duration={duration:.1f}s reason={reason} transport={bot_session.transport_type}")

     bot_session.cancel_timeout_task()

@@ -481,7 +522,7 @@ async def handle_participant_left(
                     music=music_to_save
                 )
                 bot_session.session_saved = True
-                logger.info(f"SESSION_SAVED: session_id={session_id} persona={bot_session.persona_id} images={len(images_to_save)} videos={len(videos_to_save)} ppts={len(ppts_to_save)} music={len(music_to_save)}")
+                logger.info(f"SESSION_SAVED: session_id={session_id} transport={bot_session.transport_type} persona={bot_session.persona_id} images={len(images_to_save)} videos={len(videos_to_save)} ppts={len(ppts_to_save)} music={len(music_to_save)}")
             except Exception as e:
                 logger.error(f"Failed to save session: {e}")
     except Exception as e:
@@ -492,15 +533,15 @@ async def handle_participant_left(
         if bot_session.credits_used_plan > 0 and bot_session.subscription_id:
             from src.repository.subscription_repository import subscription_repo
             await subscription_repo.add_usage(bot_session.subscription_id, bot_session.credits_used_plan)
-            logger.info(f"CREDITS_PERSISTED_PLAN: session_id={session_id} sub={bot_session.subscription_id} used={bot_session.credits_used_plan}")
+            logger.info(f"CREDITS_PERSISTED_PLAN: session_id={session_id} transport={bot_session.transport_type} sub={bot_session.subscription_id} used={bot_session.credits_used_plan}")
         if bot_session.credits_used_topup > 0 and bot_session.content_type:
             from src.repository.topup_repository import topup_repo
             await topup_repo.deduct_credits(device_id, bot_session.content_type, bot_session.credits_used_topup)
-            logger.info(f"CREDITS_PERSISTED_TOPUP: session_id={session_id} content={bot_session.content_type} used={bot_session.credits_used_topup}")
+            logger.info(f"CREDITS_PERSISTED_TOPUP: session_id={session_id} transport={bot_session.transport_type} content={bot_session.content_type} used={bot_session.credits_used_topup}")
     except Exception as e:
         logger.error(f"CREDITS_PERSIST_FAILED: session_id={session_id} error={e}")

     # Cleanup with full memory release (transcript and chunks already saved to S3)
-    logger.info(f"CLEANUP_TRIGGER: session_id={session_id} reason=participant_left")
+    logger.info(f"CLEANUP_TRIGGER: session_id={session_id} transport={bot_session.transport_type} reason=participant_left")
     await cleanup_session(session_state, bot_session, session_id, transcript_manager, chunk_state)
     await task.cancel()


Now I have a comprehensive picture. Here is the analysis:

---

## V3 vs V4 Comparison: Key Differences That Could Cause the Agent to Stop Speaking

### 1. Transport Layer: Daily -> SmallWebRTC (BIGGEST CHANGE)

**V3** used `DailyTransport` exclusively -- a managed WebRTC service where Daily handles signaling, TURN, room management, etc.

**V4** switched the primary transport to `SmallWebRTCTransport` (pipecat's built-in WebRTC with aiortc) while keeping Daily as a fallback option (`transport_type` parameter).

Key differences in transport config:
- **V3**: `audio_out_sample_rate=constants.SAMPLE_RATE_16K` (16kHz)
- **V4 SmallWebRTC**: `audio_out_sample_rate=48000` (48kHz)

This sample rate mismatch (48kHz output vs 16kHz expected by some TTS services) could cause audio issues or silence if downstream components don't resample correctly.

### 2. Event Handler Model Change (LIKELY CAUSE OF SPEAKING ISSUES)

**V3** used Daily's event model:
- `on_participant_joined` -- distinguished between "maya" bot participant and user participants
- `on_first_participant_joined` -- called `await transport.capture_participant_transcription(participant_id)`
- `on_participant_left` + separate `on_client_disconnected` (which just cancelled the task)

**V4 SmallWebRTC** uses a different event model:
- `on_client_connected` (replaces `on_participant_joined`) -- no bot/user distinction needed
- `on_first_client_connected` (replaces `on_first_participant_joined`) -- **does NOT call `capture_participant_transcription`** since SmallWebRTC doesn't have that concept
- `on_client_disconnected` now does full cleanup + cancel (combines old participant_left + client_disconnected)

**Critical finding in `handle_first_participant_joined`**:
- V3: `await transport.capture_participant_transcription(participant_id)` was called inside the shared handler
- V4: This line was **removed** from the shared handler. For Daily mode, it's called in `create_daily_first_participant_joined_handler` instead, but as a **non-awaited** call: `transport.capture_participant_transcription(participant_id)` (no `await`)

### 3. Dual Event Registration on SmallWebRTC (POTENTIAL BUG)

In v4 `main.py` lines 211-212, for SmallWebRTC:
```python
transport.event_handler("on_client_connected")(create_client_connected_handler(...))
transport.event_handler("on_client_connected")(create_first_client_connected_handler(...))
```
Both handlers are registered on **the same event** `on_client_connected`. Whether pipecat supports multiple handlers on the same event, or whether the second overwrites the first, is critical. If the second registration **overwrites** the first, then `create_client_connected_handler` (which sets `user_joined=True` and cancels timeout) would never run. If it does support multiple handlers, both should fire, but the ordering matters.

In V3, Daily had separate events: `on_participant_joined` vs `on_first_participant_joined` -- these were distinct events, so no conflict.

### 4. STT Service Change

**V3**: Used `OpenAISTTService` directly (OpenAI Whisper).
**V4**: Hardcoded to use `_create_cf_nova3_stt_service()` -- a Cloudflare Workers proxy to Deepgram Nova-3. Falls back to OpenAI on failure. If the CF worker is down/slow, STT could fail silently, preventing user speech from reaching the LLM, which means the agent would never respond.

### 5. `capture_participant_transcription` Removed for SmallWebRTC

In V3's `handle_first_participant_joined`, line `await transport.capture_participant_transcription(participant_id)` was present. In V4, this was removed from the shared handler entirely. For SmallWebRTC, this is correct (SmallWebRTC doesn't need it), but if somehow Daily transport mode is used, the call in `create_daily_first_participant_joined_handler` is **not awaited** (`transport.capture_participant_transcription(participant_id)` -- sync call). The v3 version **awaited** it. If this method is async, dropping the await could mean it never executes.

### 6. Connection Flow Difference

**V3**: Client calls `/connect-webrtc-global-agent` -> server creates Daily room -> bot starts immediately and waits for participant -> client joins room with token.

**V4**: Client calls `/start` -> server returns `sessionId` + ICE config -> client sends SDP offer to `/sessions/{id}/api/offer` -> WebRTC connection established -> bot starts. This is a 2-step process with more failure points.

### Summary: Most Likely Causes of Agent Not Speaking

1. **Dual `on_client_connected` registration** (lines 211-212 in v4 main.py) -- if pipecat overwrites handlers, the first handler (which sets `user_joined`) gets lost, and the greeting/LLMRunFrame may not fire properly.

2. **audio_out_sample_rate mismatch** -- v4 SmallWebRTC outputs at 48kHz vs v3's 16kHz. If TTS generates 16kHz audio but transport expects 48kHz, audio could be played at wrong speed or be silent.

3. **CF Deepgram STT failure** -- if the Cloudflare STT worker is unreachable, user speech never converts to text, so the LLM never generates a response after the initial greeting.

4. **Missing `await` on `capture_participant_transcription`** in the Daily fallback path.