00TL;DR
The channel event dispatcher (#1491) merged last week. SMS, Meta DMs, intake forms, and completed calls all route inbound events into the agent runtime through it. The widget becomes one more channel.
Remove text_agent entirely from the widget: drop widget_config.text_agent_id, delete the generate_text_agent_single_reply call, replace the admin picker with an ai_agent binding.
On inbound widget message → record_and_dispatch_event(RecordEventInput{ channel: Message(WebChat), … }). The agent runs a turn in a worker and replies via a new send_webchat_reply tool.
Tension: dispatcher is durable/async; the widget is a live socket the visitor waits on. Solve with a delivery-relay — the WS handler dispatches, shows typing, and relays whatever comes back on the session channel.
That relay is source-agnostic: agent reply today, a human in the inbox tomorrow. "Typing → final message" falls out for free. TypingStart/TypingStop already exist in the WS protocol.
Conversation logs come for free via ai_event + thread observability (#1493). Lead profiling (geo/IP/UA/attribution) is the part still worth extracting to bases/shared and sharing with forms.
01What landed under us
The reason the plan changed shape. Recent merges to main:
#1491 — channel event dispatcher (just merged, PR #1536)
A generalized RecordEventInput{ channel, external_id, kind, org, contact, agent_id, payload } → record_and_dispatch_event() → durable ai_event ledger + ai_event_dispatch routing/retry + a per-minute poller cron. SMS, Meta DMs, intake forms and completed calls already call it. Routing guards skip on no-agent / no-contact / empty-body / cross-org / opted-out.
Also relevant
#1493 thread observability — org activity feed + per-thread debug timeline (the "logs" the owner sees). Per-event trigger flags — e.g. phone_number.ai_agent_sms_enabled gate the dispatcher per channel. send_mode on ai_agent (autonomous vs suggest). Thread correlation generalized to multi-channel: contact:{id}:{channel}.
Net: most of what we'd have hand-built for the widget (durable dispatch, idempotency, threads, retries, observability) already exists. The widget's job is to become a channel, not to reinvent the runtime.
02Target: the widget as a dispatcher channel
Old inline path vs. new dispatched path.
Today (text_agent, inline)
widget_ws_routemessage (WebChat)TypingStartgenerate_text_agent_single_reply(text_agent_id, …) inlinemessage (ai_origin: TextAgent)Message + TypingStop over the same socketTarget (ai_agent, dispatched)
widget_ws_routemessagerecord_and_dispatch_event(Message(WebChat), msg_id, agent_id)TypingStart; subscribe to session reply channelsend_webchat_reply tool → persist outbound + publishMessage + TypingStop03The liveness problem
Why we can't just "call the dispatcher and await the reply" the way SMS does.
Every existing channel is fire-and-forget; the widget is not
SMS/Meta dispatch is tokio::spawn'd and forgotten — the reply is delivered later by a send tool hitting Twilio/Meta. There's no caller waiting. The widget visitor is waiting, and the reply has to reach one specific open WebSocket held by a different task than the agent worker.
So the agent worker can't "push to the socket" — it has no handle on it. We need a back-channel from the worker to the live handler. Three options, see Q1. The recommended one turns the WS handler into a relay: it dispatches the inbound, then listens on a per-session topic and forwards anything that arrives. This is also what makes the human-handoff (below) trivial.
04Delivery-relay & the human handoff
One mechanism serves both "typing → final message" and "a real person takes over later."
If the WS handler relays whatever lands on the session's reply topic, it doesn't care whether the author was the agent or a human teammate in the inbox. Today the publisher is the send_webchat_reply tool. Tomorrow, an inbox "reply" UI publishes to the same topic and the visitor sees it live — no widget changes needed.
Falls out for free
- Typing indicator — handler emits
TypingStartthe moment it dispatches (or when a worker claims the turn);TypingStopwhen the reply relays. Both variants already exist. - No streaming needed — agent runs a full turn, returns final text; relay pushes it once. Feels natural, not robotic.
- Human takeover — same topic, different author. The visitor's socket is already a passive relay.
What we must build
- A per-session pub/sub topic (transport TBD — Q1).
send_webchat_replyrig tool: persist outboundmessage+ publish to the topic.- WS handler: replace inline generation with dispatch + subscribe + relay loop (with a timeout fallback).
05Cutting the text_agent cord
Full removal inventory — every tendril found in mods/widget.
| Layer | What to remove / replace |
|---|---|
| Schema | widget_config.text_agent_id column + FK fk_widget_config_text_agent_id (Cascade) + index. Migration to drop, add ai_agent_id. |
| Reply gen | widget_ws_route.rs — delete generate_text_agent_single_reply(...) call + import; replace with dispatch. |
| Types | WidgetConfig, WidgetConfigData, NewWidgetSession, ResumedWidgetSession — swap text_agent_id → ai_agent_id. |
| Validation | require_text_agent_in_org() → require_ai_agent_in_org(). |
| APIs | create_widget_api / update_widget_api — accept + persist ai_agent_id. |
| UI | widget_form_component dropdown + create_widget_view / widget_details_view resources: get_text_agents_api → list ai_agents. |
| Message tags | from/to_address "agent:{text_agent_id}" + ai_origin: AiOrigin::TextAgent → agent-runtime equivalents. |
| Session plumbing | drop text_agent_id threading through resolve_session / create_widget_session / resume_widget_session. |
Stays as-is
WebSocket protocol (incl. TypingStart/TypingStop), message persistence on MessageChannel::WebChat, session resumption, lazy contact creation, and the daily_message_cap / per-connection rate window.
06The new WebChat channel
What the dispatcher needs to learn about the widget. Small surface — the structural variant already exists.
- Channel — reuse
CorrelationChannel::Message(MessageChannel::WebChat)(theMessage(..)variant +MessageChannel::WebChatboth exist). Only add the"webchat"key segment inas_key_segment()/from_key_segment(). No new top-level channel variant. - Event —
kind: AiEventKind::InboundMessage,external_id: message_id(the inboundmessageUUID, like SMS — idempotent via the unique ledger index). - Binding —
widget_config.ai_agent_id(replacestext_agent_id) suppliesagent_id. - Trigger — optional
widget_config.ai_agent_webchat_enabledflag, mirroring the phone trigger flags, to pause agent handling without unbinding. - Reply tool — new
send_webchat_replyunderai/rig/tools/messages/, curried with the session topic; surfaced to the agent only when reply context = WebChat. Extendreply_capability()so WebChat + autonomous ⇒Send. - send_mode — autonomous = agent replies live; suggest = route to a human instead of auto-replying (ties into the handoff). See Q3.
07Contact & thread model
The dispatcher requires a resolved contact; the widget creates them lazily. Order matters.
ensure_contact must run BEFORE dispatch
Routing guard skips with ContactUnresolved if no contact. The widget already calls ensure_contact() (stub on first message) — keep that, and dispatch only after it resolves. Anonymous-before-contact visitors simply don't get an agent turn until a contact exists (which is on their first message anyway).
Thread correlation key becomes contact:{contact_id}:webchat — one durable thread per contact per channel, matching the others. A returning visitor (same contact) resumes the same agent thread, which is the right behavior for a CRM. (If we ever want per-visit threads, key by session — see Q4, but per-contact is recommended.)
08Lead profiling — still shared
The original goal survives, but its scope shrinks: the dispatcher gives us the conversation log; profiling (geo/IP/UA/attribution) is the part forms have and the widget lacks.
Source (in mods/intake_form)
geo/ pipeline — trait, IPRegistry + IP-API, cache, core_conf
→ bases
bases/geo/
Consumers
forms + widget. Highest-value, drop-in.
Source
client_ip_from_headers + parse_user_agent + ParsedUserAgent
→ bases
bases/http/request_meta.rs
Consumers
forms + widget. Widget reads headers once at WS upgrade; replaces its weaker inline XFF extractor.
Source
sanitize_attribution + attribution types
→ shared
shared/ (pure string-cleaning)
Consumers
forms + widget. Lets the embed script pass UTM / ad-click IDs.
Profiling target
where the widget stores the enriched lead data
decide — Q2
enrich widget_session columns (recommended) vs. a new event table
Note
widget_session already has ip/ua/referrer/contact. Add geo + parsed UA + attribution; enrich off-path at session create.
Why no dedicated widget event table (proposed)
Forms needed intake_form_event for a micro-funnel (field-by-field). The widget's conversation timeline already lives in message rows + the agent ai_event ledger + thread observability. So profiling = enrich the session row via shared helpers; conversation log = the dispatcher. No third store. (Confirm in Q2.)
09Open decisions
Transport for the worker → live-socket reply relay?
- Reuse the existing realtime event bus, publish on a per-session/contact topic; WS handler subscribes. Consistent with
EVT_MESSAGE_NEW/EVT_THREAD_UPDATEDinfra, and a human inbox reply can publish to the same topic. - A dedicated in-process
tokio::broadcastregistry keyed bysession_token. Simpler, but single-process only and bespoke. - WS handler polls the
messagetable for new outbound rows. Robust, but laggy and chatty.
Leaning → realtime bus. Need to confirm a tool (server) can publish and the WS route can subscribe.
Where does enriched lead/profiling data land for the widget?
- Enrich
widget_sessioncolumns (geo, parsed UA, attribution) — conversation log already covered by message + ai_event. - New
widget_session_eventtable mirroring forms — full funnel parity, more code, likely redundant.
Needs your call → do you want widget analytics parity with forms, or is session-row enrichment enough?
What does send_mode = suggest mean for a live widget?
- Suggest = don't auto-reply; route to a human (the handoff path). Autonomous = agent replies live. Matches the "real person handles it" goal.
- Always autonomous for the widget; ignore suggest (a live visitor can't wait for async approval).
Leaning → suggest ⇒ human handoff, but that handoff UI is likely a follow-up issue, not v1.
Thread granularity — per contact or per session?
- Per contact:
contact:{id}:webchat— returning visitor resumes context. CRM-correct, matches other channels. - Per session: a fresh thread each visit — cleaner transcripts, loses cross-visit memory.
Leaning → per contact.
Migration of existing widgets & the deprecated text_agent path?
- Backfill
widget_config.ai_agent_idfrom each org's default/cloned agent; droptext_agent_idin the same migration. Is the widget live in prod yet (any rows to backfill)? - Keep
text_agent_idnullable for one release as a fallback, remove later. More caution, more dead code.
Needs your call → is the widget in prod with real configs?
10Phased build plan
Profiling extraction is a pure refactor; the agent re-platforming is the meat. Sequenced so each phase compiles + is reviewable.
-
P1 Extract profiling →M
bases/geo/,bases/http/,shared/Move geo pipeline, request-meta helpers, attribution sanitizer down a layer; repoint intake_form imports. Forms behavior unchanged — natural standalone PR.
-
P2 WebChat channel in the dispatcherS NEW
Add the
"webchat"key segment; build thesend_webchat_replyrig tool; extendreply_capability()for WebChat. No widget wiring yet. -
P3 Schema swap:M
text_agent_id→ai_agent_idMigration (drop FK/col, add
ai_agent_id+ optional trigger flag, backfill per Q5),just generate, update types / validation / APIs / UI picker. -
P4 Re-platformL
widget_ws_routeonto dispatch + relayDelete
generate_text_agent_single_reply; ensure_contact → dispatch → TypingStart → subscribe → relay reply → TypingStop, with a timeout fallback. Use the shared request-meta + geo on session create. -
P5 Live-verify + observability checkS
Real embedded widget session end-to-end; confirm the thread +
ai_event+ activity feed light up like forms; tune typing latency. -
P6 (Follow-up) human handoff UIS
Inbox "reply" publishes to the session topic (per Q3). Likely a separate issue once the relay is proven.