Widget → Agents Migration Mode B · brainstorming doc · 2026-06-12
Brainstorming Doc

Widget, re-platformed onto Agents

Two efforts, now one. Cut the widget's old text_agent integration and route inbound chat through the new channel event dispatcher so an ai_agent handles the conversation — while keeping the lead-profiling logic shared with intake forms. No streaming: the socket shows a typing indicator, then the final reply, leaving the door open for a real person to take over.

Cut · every text_agent tendril in mods/widget Ride · the dispatcher (#1491), like SMS / Meta / intake Share · geo / UA / attribution profiling via bases+shared

00TL;DR

01

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.

02

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.

03

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.

04

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.

05

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.

06

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.

i

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)

1Visitor msg over WS → widget_ws_route
2persist inbound message (WebChat)
3send TypingStart
4generate_text_agent_single_reply(text_agent_id, …) inline
5persist outbound message (ai_origin: TextAgent)
6push Message + TypingStop over the same socket

Target (ai_agent, dispatched)

1Visitor msg over WS → widget_ws_route
2ensure contact + persist inbound message
3record_and_dispatch_event(Message(WebChat), msg_id, agent_id)
4show TypingStart; subscribe to session reply channel
5worker runs agent turn → send_webchat_reply tool → persist outbound + publish
6handler relays reply → Message + TypingStop

03The 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.

WS handler (live socket) Agent worker (spawned by dispatcher) │ │ recv visitor msg │ │── ensure_contact + persist inbound ──► │ │── record_and_dispatch_event ─────────► │ (enqueue on thread) │── TypingStart ──► browser │ run agent turn (no stream) │ subscribe(session topic) │ │ send_webchat_reply tool: │ persist outbound + publish(session topic) │ ◄──────── reply event ───────────────────┘ Message + TypingStop ──► browser

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 TypingStart the moment it dispatches (or when a worker claims the turn); TypingStop when 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_reply rig tool: persist outbound message + 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.

LayerWhat to remove / replace
Schemawidget_config.text_agent_id column + FK fk_widget_config_text_agent_id (Cascade) + index. Migration to drop, add ai_agent_id.
Reply genwidget_ws_route.rs — delete generate_text_agent_single_reply(...) call + import; replace with dispatch.
TypesWidgetConfig, WidgetConfigData, NewWidgetSession, ResumedWidgetSession — swap text_agent_idai_agent_id.
Validationrequire_text_agent_in_org()require_ai_agent_in_org().
APIscreate_widget_api / update_widget_api — accept + persist ai_agent_id.
UIwidget_form_component dropdown + create_widget_view / widget_details_view resources: get_text_agents_api → list ai_agents.
Message tagsfrom/to_address "agent:{text_agent_id}" + ai_origin: AiOrigin::TextAgent → agent-runtime equivalents.
Session plumbingdrop text_agent_id threading through resolve_session / create_widget_session / resume_widget_session.
i

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) (the Message(..) variant + MessageChannel::WebChat both exist). Only add the "webchat" key segment in as_key_segment()/from_key_segment(). No new top-level channel variant.
  • Eventkind: AiEventKind::InboundMessage, external_id: message_id (the inbound message UUID, like SMS — idempotent via the unique ledger index).
  • Bindingwidget_config.ai_agent_id (replaces text_agent_id) supplies agent_id.
  • Trigger — optional widget_config.ai_agent_webchat_enabled flag, mirroring the phone trigger flags, to pause agent handling without unbinding.
  • Reply tool — new send_webchat_reply under ai/rig/tools/messages/, curried with the session topic; surfaced to the agent only when reply context = WebChat. Extend reply_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

Q1

Transport for the worker → live-socket reply relay?

  • A dedicated in-process tokio::broadcast registry keyed by session_token. Simpler, but single-process only and bespoke.
  • WS handler polls the message table 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.

Q2

Where does enriched lead/profiling data land for the widget?

  • New widget_session_event table 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?

Q3

What does send_mode = suggest mean for a live widget?

  • 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.

Q4

Thread granularity — per contact or per session?

  • Per session: a fresh thread each visit — cleaner transcripts, loses cross-visit memory.

Leaning → per contact.

Q5

Migration of existing widgets & the deprecated text_agent path?

  • Keep text_agent_id nullable 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 → bases/geo/, bases/http/, shared/
    M

    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 dispatcher
    S NEW

    Add the "webchat" key segment; build the send_webchat_reply rig tool; extend reply_capability() for WebChat. No widget wiring yet.

  • P3 Schema swap: text_agent_idai_agent_id
    M

    Migration (drop FK/col, add ai_agent_id + optional trigger flag, backfill per Q5), just generate, update types / validation / APIs / UI picker.

  • P4 Re-platform widget_ws_route onto dispatch + relay
    L

    Delete 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 check
    S

    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 UI
    S

    Inbox "reply" publishes to the session topic (per Q3). Likely a separate issue once the relay is proven.