Esta página aún no está traducida al español — mostrando la versión en inglés.

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_offReturn to AI + Close
    • closedReopen
    • 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:

  1. If the conversation isn't handed_off: flashes "Can only reply to handed-off conversations." and redirects back. Nothing is created.
  2. Otherwise:
    • Persists an agent message via Chat.create_agent_message(conversation, content, user).
    • Broadcasts agent_message on the Phoenix channel chat:{session_id} with %{content, agent_name}. The agent_name is user.first_name || user.email.
    • If the conversation's channel is "whatsapp" or "telegram", enqueues Chatbotgen.Workers.AgentReplyDelivery.
    • Redirects back to the conversation page.

External channel delivery

Verified from AgentReplyDelivery.perform/1:

  • WhatsApp: loads whatsapp_config. If present AND status == "connected", strips the "wa:" prefix from session_id to obtain the JID, then calls Whatsapp.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 from session_id to obtain the chat ID, then calls Telegram.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" → calls Chat.close_conversation/1. Flash on success: "Conversation closed."
  • status: "active" → calls Chat.reopen_conversation/1. Flash on success: "Conversation returned to AI." AND broadcasts handoff_ended (empty payload) on chat:{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:

  1. Desktop notification (only when notification_settings.desktop is true AND Notification.permission === "granted"): creates a new 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}.
  2. Audio chime (only when notification_settings.sound is true): plays /sounds/handoff.mp3 at volume 0.6. Autoplay errors are swallowed.

Both preferences come from Notifications. The hook is mounted globally in the app layout.