Replying as an agent
Send replies, return to AI, close, or reopen a conversation. Works across widget, WhatsApp, and Telegram.
When a conversation's status is handed_off, the reply form and status buttons in the transcript panel are active. This page documents each one, backed by AgentReplyController, ConversationStatusController, and AgentReplyDelivery.
When reply is available
Verified from MessagePanel in Conversation/Index.jsx:
-
Reply form is rendered only when
conversation.status === "handed_off". -
Header action buttons depend on the status:
-
handed_off→ Return to AI + Close -
closed→ Reopen - anything else → no status buttons
-
Reply form
┌────────────────────────────────────────────────────────────────┐
│ [ Type your reply... ] [ Send ] │
└────────────────────────────────────────────────────────────────┘
-
Input type:
text - Placeholder: "Type your reply..."
- Enter (without Shift) submits
- Send button disabled when processing OR when trimmed content is empty
Submit posts POST /app/chatbots/{chatbot_id}/conversations/{conversation_id}/replies with content.
What Send does
Verified from AgentReplyController.create/2:
-
If the conversation isn't
handed_off: flashes "Can only reply to handed-off conversations." and redirects back. Nothing is created. -
Otherwise:
-
Persists an agent message via
Chat.create_agent_message(conversation, content, user). -
Broadcasts
agent_messageon the Phoenix channelchat:{session_id}with%{content, agent_name}. Theagent_nameisuser.first_name || user.email. -
If the conversation's channel is
"whatsapp"or"telegram", enqueuesChatbotgen.Workers.AgentReplyDelivery. - Redirects back to the conversation page.
-
Persists an agent message via
External channel delivery
Verified from AgentReplyDelivery.perform/1:
-
WhatsApp: loads
whatsapp_config. If present ANDstatus == "connected", strips the"wa:"prefix fromsession_idto obtain the JID, then callsWhatsapp.client().send_message(connection_id, jid, content). If not connected or no config, logs a warning and does nothing. -
Telegram: loads
telegram_config. If present, strips the"tg:"prefix fromsession_idto obtain the chat ID, then callsTelegram.client().send_message(bot_token, chat_id, content). If no config, logs a warning and does nothing. -
Any other channel value:
:ok, no delivery (widget conversations are handled by the real-time broadcast above).
The worker runs in the :default Oban queue with max_attempts: 3.
Status buttons
All three (Return to AI, Close, Reopen) submit PUT /app/chatbots/{chatbot_id}/conversations/{conversation_id}/status with status.
Verified from ConversationStatusController.update/2:
-
status: "closed"→ callsChat.close_conversation/1. Flash on success: "Conversation closed." -
status: "active"→ callsChat.reopen_conversation/1. Flash on success: "Conversation returned to AI." AND broadcastshandoff_ended(empty payload) onchat:{session_id}. -
Anything else →
{:error, :invalid_status}. Any error path flashes "Failed to update conversation status."
Status doesn't auto-reset after replying
Sending a reply does not change the conversation status. It remains handed_off (and continues to show the reply form and the Return to AI / Close buttons) until you click one of those.
Real-time notifications for new handoffs
Verified from assets/js/hooks/use-handoff-notifications.js: this hook listens on the account channel for a handoff event and, per user:
-
Desktop notification (only when
notification_settings.desktopis true ANDNotification.permission === "granted"): creates anew Notification("New handoff — {chatbot_name}", { body: "{visitor_name}: {reason}", tag: "handoff-{conversation_id}", requireInteraction: true }). Clicking the notification focuses the window and navigates to/app/chatbots/{chatbot_id}/conversations/{conversation_id}. -
Audio chime (only when
notification_settings.soundis true): plays/sounds/handoff.mp3at volume 0.6. Autoplay errors are swallowed.
Both preferences come from Notifications. The hook is mounted globally in the app layout.
Related
- Conversations overview
- Reading transcripts
-
Human handoff — what flips a conversation to
handed_off