The Unified Inbox puts every customer conversation on one screen: support tickets, form submissions, live AI chat, and two-way SMS threads merge into a single list with needs-attention triage, and staff reply inline — ticket replies through the Ticket System's normal write path, form replies by email, chat replies straight into the visitor's open widget, texts through the Twilio number already configured for Marketing. SMS was the net-new channel in v1 (the Twilio webhook previously handled only STOP/START keywords and discarded message bodies); v2 adds the form-submission channel and live chat takeover.
The problem
Customer conversations arrive through four doors — the support form, the site's own forms, the AI chat bubble, and text messages to the business number — and each lives on its own dashboard page (or, for SMS, nowhere at all: inbound texts were dropped after opt-out keyword handling). Nothing shows "what needs a human right now" across all of them. Two of those doors were also one-way: a form submission had no reply affordance anywhere in the CMS, and a chat the bot couldn't answer had no way for a person to step in.
The fix
A self-contained feature module under app/Features/Inbox/ that aggregates rather than duplicates: tickets stay in the Tickets module's tables, chat transcripts stay in the AI Chat module's tables, form submissions stay in core's form_submissions, and the inbox reads all of them. The module owns storage only for what it introduces — the SMS channel (inbox_sms_threads + inbox_sms_messages) and the record of form replies it sends (inbox_form_replies).
Like every addon, it's structured as a feature module gated by feature:inbox middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations.
What the dashboard gets
Under Dashboard → Inbox (Conversations need Manager and up; Settings is Admin-only):
- Conversations — one merged list across the four channels, newest activity first, each row showing a channel icon, the contact (requester name, "Visitor" for anonymous chats, the submitter for forms, phone or matched CRM contact name for SMS), a snippet of the latest message, relative time, a status badge, and an amber needs-attention dot. Tabs: All / Needs attention (tickets awaiting a staff reply + SMS threads with unread messages + submissions still marked new) / Tickets / Chats / Forms / SMS, plus search. Channels gate individually — the Tickets tab and rows appear only when the Ticket System is enabled, Chats only when AI Chat is enabled; Forms and SMS are core/inbox-owned and always present.
- Ticket pane — the full thread (customer messages, staff replies, amber internal notes) with status quick-actions and an inline composer (reply or internal note). Replies go through the Ticket System's own write path, so the requester is emailed and the status flips to Awaiting reply exactly as if sent from the ticket page — which stays one click away via Open full ticket.
- Chat pane — the live transcript with Take over / Hand back to the assistant and a reply composer. See Live chat takeover below.
- Form pane — the submitter's answers as a label/value list, status quick-actions (new / read / replied / closed, shared with the Forms page), the replies already sent, and an email composer. See The form-submission channel below.
- SMS pane — the message thread (inbound left, staff replies right, sender name on each) with a reply box. Opening a thread clears its unread counter. Replies are blocked with a clear message when the number has texted STOP (carriers block those sends anyway) and when no SMS provider is configured; sends that fail at the provider are surfaced as an error and never recorded, so the thread only ever shows texts that actually left.
- Settings (admin only) — the inbound-capture toggle (on by default; turning it off keeps STOP/START compliance handling but stops storing message bodies), the Twilio webhook URL to paste into the number's "A message comes in" hook, a connection status check, and a pointer to Marketing → Settings where the Twilio credentials live (the inbox deliberately has no second credential store).
The dashboard also gets a Unified Inbox card (unread SMS + tickets awaiting reply) linking straight to the inbox, and the sidebar badge shows the same needs-attention count.
The SMS channel
- Inbound — the existing signature-verified Twilio webhook (
POST marketing/sms/webhook) gains a capture step: after STOP/START keyword processing, the message body is appended to the sender's thread (created on first contact, keyed by E.164 phone) and the unread counter bumps. STOP/START texts are recorded too, so the thread shows the full exchange. Capture is best-effort — a storage failure never breaks the webhook response Twilio expects. - Outbound — replies send through the Marketing module's
SmsSender(Twilio) and are recorded only on send success, stamped with the staff sender. - Opt-outs — the Marketing module's
SmsOptoutsuppression list is checked before every reply; opted-out numbers can't be texted from the inbox until they text START. - CRM matching — on thread creation the phone is matched against CRM contacts (digit-normalized, country-code tolerant) and the thread points at the contact; merged duplicate contacts repoint their threads to the survivor automatically.
Missed Call Text Back
Turns the Twilio number into a call-forwarder that never loses a lead: calls forward to the owner's real phone, and any call that doesn't connect gets an instant text so the conversation continues in the SMS thread.
Setting it up
- Connect Twilio first (skip if two-way SMS already works). On Dashboard → Marketing → Settings, choose Twilio as the SMS provider and enter the Account SID, auth token, and the From number (or Messaging Service SID). Missed Call Text Back sends through these same credentials — it has no credential store of its own, and the voice webhooks authenticate against this auth token.
- Configure the feature on Dashboard → Inbox → Settings → Missed Call Text Back:
- Forward calls to — the owner's real phone, with country code (
+1903…). This is where calls to the Twilio number ring. - Ring timeout — default 20 s. Must be shorter than the forward phone's carrier-voicemail pickup (usually ~25 s): if voicemail answers, Twilio sees the call as answered and no text is sent.
- Wait between texts — default 4 h; the same caller isn't auto-texted twice inside the window (repeat calls still log a note in the thread).
- Text message — the auto-text;
:businessinserts the business name from Settings → Business. - Flip Text back missed calls on and save.
- Forward calls to — the owner's real phone, with country code (
- Point the number's voice hook at the site. In the Twilio console → Phone Numbers → the number → Voice Configuration, set "A call comes in" to Webhook, HTTP POST, with the URL shown at the bottom of the settings card (
https://{site}/inbox/voice/incoming). The card has a copy button. (The "A message comes in" hook should already point at the SMS webhook from the two-way SMS setup.) - Test it: call the Twilio number from a mobile, let it ring past the timeout without answering. You should hear "we just sent you a text," receive the text on the calling phone, and see a
📞 Missed callnote plus the auto-text in a new inbox thread for that number. Calling again immediately texts nothing (throttle) but adds another note.
The number must be SMS-capable (and A2P-registered for US traffic) or the send fails — the call is still logged and noted, the caller just hears the apology instead of the text promise.
How it decides
The number's "A call comes in" hook points at POST inbox/voice/incoming.
- Call flow —
inbox/voice/incoming(signature-verified, like the SMS webhook) answers with<Dial timeout="N" action="inbox/voice/dial-status">forward-number</Dial>. The action callback runs the decision matrix inInboxMissedCallResponder— the ONE implementation shared by every entry point. - The decision matrix — a text goes out only when ALL of these hold: the dial ended
busy/no-answer/failed/canceled(canceled= the caller hung up mid-ring — still worth texting;completedmeans somebody — possibly the owner's carrier voicemail — answered), the CallSid hasn't been handled before (Twilio retries the callback;inbox_missed_calls.call_sidis unique, so a retry can't text twice), the caller ID is a real E.164 number (anonymous/withheld markers are ignored outright), the number hasn't replied STOP, and it wasn't already texted inside the throttle window (default 4 h, sent texts only — a failed send doesn't block a later attempt). - Ring timeout beats voicemail — the classic failure is the owner's carrier voicemail answering the
<Dial>, which reads ascompleted: no text, and the caller is stranded in personal voicemail. The timeout (default 20 s, capped at 55) must stay shorter than the voicemail pickup; the settings page says so. - Thread recording — every real-caller missed call drops a
📞 Missed callsystem note (message directionnote) into the phone's SMS thread and bumps needs-attention; when the text sent, it's recorded as the outbound message too, so the caller's reply lands in a thread that already shows the whole story. Theinbox_missed_callstable is the call log + throttle source. - Sender continuity — the text sends from the number the caller just dialed (the callback's
To), overriding a Messaging Service SID's pool choice, so the reply thread matches the call. - Caller-facing TwiML — "we just sent you a text" is only said when a text actually sent; skips get a brief apology; answered/duplicate callbacks end silently.
- Voice receptionist compat (dormant) —
POST inbox/voice/missed-call-eventaccepts the signed mothership event the receptionist will relay in combined mode (HMAC-SHA256 overtimestamp.bodywith the per-installinbox.voice_event_secret; fails closed while unprovisioned). Itsai_engagedflag skips the text when the AI took the call, unlessinbox.missed_call_always_textis set. Same responder, same matrix.
v2 capabilities
- Whisper screening (
inbox.missed_call_whisper) — whoever answers the forwarded leg hears "Incoming call for {business}. Press any key to accept" before the bridge (<Number url>→inbox/voice/whisper, Gather →whisper-connect). No keypress falls through to<Hangup/>, so a carrier-voicemail answer ends the leg and the dial reads as missed — this replaces racing the ring timeout as the voicemail defense. (The exact DialCallStatus a screened-out leg produces still needs one real-call observation.) - Business hours + after-hours modes — an optional
HH:MMwindow in the site timezone (inbox.missed_call_hours_start/_end; supports midnight wrap) with three after-hours behaviors (inbox.missed_call_after_hours_mode):forward(default — ring anyway),text_first(skip the dial, text immediately; logged under the pseudo-statusafter-hours), andhold_texts(ring, but a missed call's text queues until the next opening — the thread note still records immediately). Held texts drain via theinbox:send-queued-missed-call-textsLazyCron (5 min), which re-checks STOP opt-outs at send time. - Per-number routing (
inbox.missed_call_number_routes) — for multi-location installs with several Twilio numbers: each dialed number forwards to its own phone; unlisted numbers use the default; texts always send from the number the caller dialed. - Voicemail fallback (
inbox.missed_call_voicemail) — a missed call that got no "we texted you" (and a held one) is invited to leave a message;<Record transcribe>posts the transcription toinbox/voice/transcription, which lands it in the caller's thread as an inbound "🎙 Voicemail:" message (bumps needs-attention). Never offered when the text actually sent. - The log + ROI card — the settings page lists the recent
inbox_missed_callsrows (result badges: Texted / Queued until X / Throttled / Opted out / Send failed), and a dashboard card (visible once the feature is enabled) shows the 30-day story: missed calls caught, texted back, and how many callers replied afterwards.
The form-submission channel
Every form on the site already collected submissions; nothing in the CMS could answer one. The inbox adds that.
- What appears — every non-spam submission, newest first. Spam is excluded outright (the Forms page has its own review queue for it), and a submission counts as needing attention while its status is still
new. Opening one advancesnew→read, but never walks a later status backwards, so a manual replied / closed survives a second look. - Who sent it — resolved from the form's own field definitions: the first
email-type field is the address,name/full_name(orfirst_name+last_name) is the display name.FormSubmission::submitterEmail()/submitterName()/scalarAnswers()are the shared accessors; answers whose field was later removed from the form still render, so a redesign never hides what a visitor actually wrote. - Replying emails them and records the sent copy in
inbox_form_replies, then stamps the submissionreplied. The send happens first and the record only on success, so the pane never shows a reply that never left — the same contract the SMS channel uses. Blocked with a clear message when the form has no email field, or when no mail provider is configured. - Their answer does NOT come back into the CMS. A form submission is a one-shot inbound message; a reply to it lands in the site's mailbox like any transactional email. The pane says so, and points at the escalation that fixes it.
- Open ticket (when the Ticket System is on) turns the submission into a real two-way thread — the answers ride in as an internal note and the submitter's name and email arrive pre-filled. This is the path to take when the conversation needs to continue: ticket replies come back into the CMS by email, form replies don't.
Live chat takeover
In v1 an AI chat was a read-only transcript and the only handoff was opening a ticket. Now a person can step into the conversation while the visitor is still on the page.
- Taking over — Take over claims the conversation, and sending a reply takes it over implicitly, so a staff message can never land while the bot is still answering. Either way
ai_chat_conversations.taken_over_atis stamped with the staff member ontaken_over_by. - The bot stands down for exactly as long as that timestamp is set. The widget stops dispatching
ask()after a visitor message (the message still persists — it's waiting for a human, not lost), andask()itself re-reads the flag from the database and refuses to run.ask()is a public Livewire action, so the stand-down can't rely on client state. - The visitor sees it live. Staff messages are stored as
role = 'staff'with the author'suser_id, render in their own bubble labelled with the staff member's name, and the widget's footer disclosure switches from "Answers are AI-generated" to "A member of our team is replying." - Polling is scoped to visitors who are actually in a conversation. The widget emits
wire:pollonly once the visitor has sent at least one message — someone who never opens the chat costs nothing — at 20s while the bot is handling it (just enough to notice a human joining) and 5s once one has. A poll landing mid-stream is skipped so it can't blank a partially streamed reply. - Handing back clears the flag and the next visitor message wakes the bot. The staff turns stay in the transcript and are handed to the model as
assistantturns (providers accept only user/assistant, and a human reply is still our side of the exchange) — so the bot picks up with the full context of what the person said. - Transcripts name the speaker. A chat escalated to a ticket attaches "Visitor:", "Assistant:" and the staff member's own name, rather than flattening a human reply into "Assistant".
What it deliberately doesn't do
- No Facebook / Instagram DM channels — planned for a future version.
- No inbound threading on form replies — a reply is an email out, not a conversation. Escalate to a ticket when you need two-way.
- No second SMS credential store — Twilio credentials live on the Marketing settings page only.
- No push/WebSocket delivery for chat takeover — the widget polls. That keeps it working on shared hosting with no queue worker or socket server, which is the deployment floor this CMS targets.
Key files
| Piece | Path |
|---|---|
| Service provider | app/Features/Inbox/InboxServiceProvider.php |
| SMS write paths (webhook capture + staff reply) | app/Features/Inbox/Support/InboxSmsActions.php |
| Form-reply write path | app/Features/Inbox/Support/InboxFormActions.php |
| Submitter resolution + answer list | app/Models/FormSubmission.php (submitterEmail, submitterName, scalarAnswers) |
| Chat takeover write path | app/Features/AiChatBot/Support/ChatTakeover.php |
| Chat widget (stand-down + staff polling) | app/Features/AiChatBot/resources/views/widget/⚡chat.blade.php |
| Webhook capture hook | app/Features/Marketing/Http/Controllers/MarketingPublicController.php smsWebhook() |
| Models | app/Features/Inbox/Models/ (InboxSmsThread, InboxSmsMessage, InboxFormReply) |
| Dashboard | app/Features/Inbox/resources/views/dashboard/ (⚡index, ⚡settings) |
| CRM merge repoint | app/Features/Crm/Support/CrmMerge.php mergeContacts() |