AI Chat puts a streaming AI assistant in a chat bubble at the bottom-right of the public site. It answers visitor questions by actually searching the site's published content (and the knowledge base, products, and more when those features are on), cites real URLs, and — when it can't answer or the visitor needs a human — hands off cleanly: it can book appointments, open support tickets with the full transcript attached, look up order status, and capture follow-up leads into the CRM.
The problem
Most site chat widgets are either a third-party bot trained on nothing (so it answers from thin air), or a live-chat inbox nobody staffs after 5pm. Visitors ask reasonable questions — "do you deliver on Saturdays?", "where's my order?", "how do I reset my password?" — and either get hallucinated answers or silence. And when the bot can't answer, that signal (the exact question a real visitor needed answered) evaporates instead of feeding the site's documentation.
The fix
A self-contained feature module under app/Features/AiChatBot/ runs an agentic loop against the install's configured AI provider (AiTextService — Claude, OpenAI, Google, or DeepSeek; no extra dependency). The model gets tools, not a memorized site: it searches the real page index — by meaning as well as by keyword — and answers with links, reads full pages when snippets aren't enough, and refuses to invent URLs or facts. Conversations persist to the database with a dashboard viewer, a configurable retention window, and suggested privacy-policy wording.
What the assistant can do
Tools register only when their backing feature is enabled and the matching toggle in AI Chat Settings is on:
| Tool | Backing feature | Setting | What it does |
|---|---|---|---|
| Search site content | — (core) | ai_chat.search_site_enabled |
Searches published pages, posts, events, locations, and content; answers cite the returned URLs. Every call also runs a meaning-based pass and appends a Related pages (matched by meaning) group — see Search by meaning, not just keywords. |
| Read a page | — (core) | always on | Fetches the full text of one public page when a search snippet isn't enough. Role-gated pages refuse. |
| Look up location details | — (any content type with an Address field) | ai_chat.locations_enabled |
Answers address / phone / SMS / fax / opening-hours / online-booking questions from the location record's own structured fields — the one place site search cannot reach, because those live in a JSON data bag and every search snippet resolves to a prose blurb. On a multi-location site a question with no location named returns the roster and no contact details at all, so the assistant has to ask which location before it can answer. A contact field with nothing entered comes back as an explicit null, which the assistant reports as "we don't list a fax number for that clinic" plus an offer to have the team follow up — never as "I couldn't find one". A location's own Schedule / Book URL rides the same payload, so "can I book an appointment at X?" is answered with that location's booking link rather than a general contact page — and reports as null, like the contact fields, where the location publishes none. Email fields are deliberately excluded, mirroring business.email's exclusion from the AI brief. |
| Search the knowledge base | Documentation | ai_chat.search_kb_enabled |
Searches published doc articles (translations included), by meaning first — a support question rarely repeats the wording of the article that answers it. Keyword is the fallback. |
| Talk to a person | — (core) | ai_chat.handoff_enabled |
Hands the conversation to a member of the team. Unusually for a tool, it can refuse: it reports whether anyone is actually reachable and the assistant follows that verdict, so "connecting you now" is never said into an empty dashboard. See Escalating to a person. |
| Search store products | Ecommerce | ai_chat.products_enabled |
Catalog search returning name, price, sale price, stock status, and product URL — answers link straight to the product page. |
| Search property listings | Real Estate / MLS | ai_chat.listings_enabled |
Structured inventory search (location free-text, min/max price, beds, baths, status, sort) through the same ListingSearch brain as the /properties page — returns address, price, beds/baths, city, and the listing URL, plus a link to the full search page when results are capped. The system prompt steers "homes for sale in …" questions here instead of the generic site search, with the place name and budget split into separate filters. |
| Search the restaurant menu | Restaurant | ai_chat.menu_enabled |
Live menu search (dish/ingredient keyword and/or one dietary tag) over available items on active menus — returns name, description, price, dietary tags, section, and any limited-availability schedule, plus the order page URL. Answers "do you have vegan options?" from the real menu. |
| Search the directory | Directory | ai_chat.directory_enabled |
Searches published, unlapsed directory entries with the keyword and the city as separate structured filters — returns name, tagline, address, phone, website, and the entry URL. |
| Search courses | Courses | ai_chat.courses_enabled |
Catalog search over published courses — returns title, summary, lesson count, an access-aware price ("$99.00", "Free", "Included with membership", or "Requires a membership plan"), and the course URL. |
| Share donation campaigns | Donations | ai_chat.donations_enabled |
Lists campaigns currently accepting gifts with goal, amount raised, progress %, time remaining, and each campaign's active member-run fundraisers (with their pages), plus the donate page URL. |
| Look up event tickets | Events Calendar (event ticketing) | ai_chat.event_tickets_enabled |
Finds published events by name and returns each on-sale ticket type's current price (early-bird aware, with the regular price and deadline alongside), seats remaining, and sold-out status — the live data the page index can't carry. |
| Share membership plans | Memberships | ai_chat.membership_plans_enabled |
Lists active membership plans with price + billing interval ("$20.00/mo"), trial days, and category, plus the registration page URL. Plans without a fixed price show "Custom pricing". |
| Quote customer reviews | Reviews | ai_chat.reviews_enabled |
Answers reputation questions ("what do customers say about X?") from your published reviews — returns rating, author, text, source, plus the overall average and review count. Hidden and unmoderated reviews are never exposed, and the tool tells the model not to invent one when nothing matches. |
| Save a follow-up request | CRM (+ crm.capture_chat) |
— | Captures name/email/phone + reason as a CRM contact (source Chat Bot) with a timeline interaction linked to the conversation. |
| Sign up to the newsletter | Marketing | ai_chat.newsletter_enabled |
Adds a visitor who asks to subscribe to the mailing list (source ai-chat). Follows the public signup form's rules exactly: with double opt-in on it sends the confirmation email instead of subscribing, and a past opt-out is only undone when double opt-in is off. Rate-limited per IP. |
| Book an appointment | Online Booking | ai_chat.booking_enabled |
Lists services, checks real availability, and books free services for a visitor who confirmed a specific time (paid services are sent to the /book page). Rate-limited per IP. |
| Open a support ticket | Ticket System | ai_chat.tickets_enabled |
Opens a ticket through the Tickets module's normal path (confirmation email, auto-assign, CRM capture) and attaches the full chat transcript as an internal staff note — staff read the whole conversation from the ticket; the visitor's public thread never shows it. Rate-limited per IP. |
| Check order status | Ecommerce, Restaurant, or Event Ticketing | ai_chat.orders_enabled |
Looks up an order by order number + email together — both must match or nothing is revealed, so the bot can never leak someone else's order. Shop orders return status, items, total, and tracking link; restaurant orders return status, fulfillment, and the tokenized tracking page; event ticket orders return status, event, date, total, and the tokenized tickets page. |
Tool execution never throws — a failing tool returns a polite error string the model can relay, so a misbehaving query can't crash a conversation.
Search by meaning, not just keywords
Both search tools are backed by Semantic Search (a member feature, on by default for members), and it matters most for exactly the questions visitors actually ask. Someone describes their problem in their own words — "my lights keep flickering", "it keeps logging me out" — and shares no word at all with the page that answers it ("electrical repair", "Session timeout"). Keyword search returns nothing, the assistant reads nothing, and it tells the visitor the site doesn't cover it.
- Both tools take two inputs. A short, keyword-shaped
queryfor the LIKE/FULLTEXT index, which narrows on every added word — and the visitor's need as one full sentence inquestion, which is what meaning-matching scores. One string can't serve both engines, so the system prompt instructs the model to send both shapes; with noquestion, meaning-matching falls back to scoring the keyword query. - Site search adds a meaning pass on every call, not just when keyword search came back empty — the worse failure is a keyword search returning plausible-but-wrong pages, which the assistant then reads and correctly fails to answer from. The extra hits arrive as their own group, deduped by path against the keyword results, so the pass can only ever add a page the keyword engine missed — the keyword groups themselves are never reordered or filtered by it.
- The knowledge base goes meaning-first, with keyword as the fallback — still the better engine for an exact lookup like an error code or a setting name.
- Role gating rides along. Retrieval is scoped to the current visitor's role, so a staff-only page can no more surface through the assistant than through site search.
- It degrades, never errors. An install with no embedding provider — or one mid-outage, or one without the member feature — silently falls back to keyword search. A separate keyword-only rescue also retries a fruitless search on its distinctive words alone, so even an install with no AI search at all gets a second attempt.
What gets embedded, the relevance floor retrieval applies, and where the vectors live are all in semantic-search.md — the canonical source for that layer. (Visitor-facing site search is keyword by default; the same index backs the Keywords / Meaning toggle on the search overlay and the dashboard palette — see that doc's "Visitor search" section.)
Unanswered questions → documentation gaps
Every reply runs through a cheap, honest "did the bot actually answer?" heuristic (ChatAgent::lastRunUnanswered()): a run is flagged when every search the agent ran came up empty (language-independent) or the final reply contains an explicit can't-find phrase (ChatResolution) — unless an action tool (booking, ticket, order lookup, contact capture) succeeded, in which case the visitor was helped.
Flagged questions land on the Unanswered questions tab of Dashboard → AI Chat, grouped by question text with a times-asked count and last-asked date. Each row offers:
- Create doc (when Documentation is on) — spins up a draft knowledge-base article titled after the question and drops you in the editor (the Documentation module's auto-draft pattern). The question clears from the report.
- Dismiss — clears every occurrence of that question.
This is the feedback loop: real visitor questions the bot couldn't answer become the exact list of articles worth writing — and once written, the bot answers them via knowledge-base search.
Lead capture on unresolved conversations
Two layers, both respecting crm.capture_chat:
- Explicit — the
save_contact_requesttool (above), when the visitor asks for a follow-up. The system prompt also tells the assistant to offer this before giving up on a question it couldn't answer. - Automatic — when a reply is flagged unanswered and the visitor shared an email address anywhere in the conversation,
ChatLeadCapturecreates the CRM contact with source AI Chat (ai-chat) and logs the unanswered question as an interaction linked to the conversation. Fire-and-forget (a capture failure never breaks the reply) and idempotent per conversation vialead_captured_at— the explicit tool stamps the same column, so the two paths never double-log.
Notifying the team
A captured lead is otherwise silent — it lands on the contact record and waits for someone to open the dashboard, which on one live install meant a follow-up request sat three days before anyone saw it. AI Chat → Settings → Lead notifications takes a comma-separated list of addresses (ai_chat.lead_notify_email); when set, ChatLeadNotifier emails them the moment either capture path fires, with the visitor's name/email/phone, the reason, and the tail of the transcript. Reply-to is the visitor, so Reply answers them directly.
Blank inherits the contact form's notification address, resolved live from the seeded contact form (forms.is_seeded, the only form the initial-schema migration inserts; falling back to the oldest type = contact form with an address). A site has one "who handles inbound enquiries" mailbox, and asking an owner to remember a second place to update it is how the two drift apart — so updating the contact form moves both, with nothing to re-save here. The field is an override, and the settings page shows the inherited address as the input's placeholder so blank is never a mystery.
Consequently the Setting is never stored as an empty string: clearing the field DELETES the row (ChatLeadNotifier::store()), because '' would read as "override to nobody" and silently switch the notification off the first time anyone saved the page. Absent = inherit, present = override. Nothing is sent only when the contact form has no address either.
Fire-and-forget on the same terms as the capture itself: a mail failure is reported and swallowed, never surfaced to the visitor who has already been told the team will be in touch. The Mailable carries primitives rather than the conversation model, so nothing re-queries if the conversation is pruned between composing and sending. The settings section warns when crm.capture_chat is off, since the assistant then has no capture tool and no notification could ever be sent.
Proactive chat prompt
An admin-configured nudge — "Need help choosing a plan?" — that fires client-side (Settings → Proactive chat prompt):
- Trigger: time on page (N seconds), scroll depth (N%), or exit intent (desktop pointer leaving the viewport top; touch devices simply never fire it).
- Mode: a dismissible message bubble next to the chat button, or auto-opening the chat panel with the message shown as a client-only assistant bubble (never persisted, never sent to the model).
- Page targeting: path patterns, one per line,
*wildcard (/pricing,/shop/*); empty = every page. - Frequency: once per visit (session) or once every N days. Dismissing sticks forever — the X on the bubble (or engaging with it) writes a permanent suppression stamp, popups-style.
- Re-showing: suppression stamps are keyed by a generation counter; the "Show again to visitors who dismissed it" checkbox bumps it once on save. A typo fix never re-blasts dismissers — re-surfacing is always an explicit choice.
Cache-safe by design: the trigger config rides the widget (a per-visitor lazy island), and all timing/targeting/frequency enforcement happens in the browser (resources/js/ai-chat-nudge.js, lazy-imported by public.js), so cached guest HTML stays identical for every visitor.
What visitors see
- A brand-colored chat bubble bottom-right (light or dark theme). Which brand family it paints with — Primary or Secondary — is the Widget color setting; primary is the default, and secondary is the fix for sites whose primary is also the header/CTA color, where the bubble otherwise disappears into the page. The chosen family drives the bubble, the send button, the header glyph, the composer's focus ring, the visitor's own message bubbles, and the tint on a staff reply (
ChatAccent— its class strings are complete literals because Tailwind scans that file as source text). Managers+ see the bubble hidden by default with a toggle — the editor's edit-page button keeps priority placement. - Streaming replies with markdown (links render as links; HTML is stripped). A tool-using turn shows its "let me look that up" preamble, then replaces it with the clean final answer.
- A disclosure line under the input: answers are AI-generated and may be inaccurate; conversations may be stored.
- Rate limiting (15 messages / 5 min per visitor) and a 40-message conversation cap.
Live takeover (a person steps in)
With the Unified Inbox enabled, staff can take a conversation off the bot while the visitor is still on the page — either explicitly (Take over) or just by sending a reply.
- The bot stands down completely while
ai_chat_conversations.taken_over_atis set: the widget stops dispatchingask()after a visitor message (the message still persists, waiting for the human), andask()re-reads the flag from the database and refuses to run — it's a public Livewire action, so the stand-down can't rely on client state. - Staff turns are their own role.
ai_chat_messages.role = 'staff'with the author onuser_id; the visitor sees a distinct bubble labelled with the staff member's name, and the disclosure line switches to "A member of our team is replying." - The widget polls rather than using sockets, so it works on shared hosting with no queue worker: nothing at all until the visitor has sent a message, then 20s while the bot is handling it, 5s once a human has taken over. 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, which reads the human's turns as
assistanthistory (providers accept only user/assistant, and a staff reply is still our side) — so it resumes with full context.
Escalating to a person (“talk to a human”)
Off by default — Settings → Talk to a person. Once on, the assistant gets a request_human tool and a visitor can simply ask for someone.
The design point is that the tool can refuse. It resolves whether anybody is actually reachable and reports back, so the bot never promises a person into an empty dashboard:
- Someone is on duty → the conversation joins the waiting queue, the console badges + chimes, and the bot says it's bringing someone in and then stops talking. The stand-down is the same one a live takeover uses (
escalation_requested_atset,taken_over_atstill null), so the assistant can't talk over the human who is about to type. - Nobody is on duty → the bot says so plainly, hands over the phone/email the owner published, and offers to take the visitor's details via
save_contact_request. Nothing is queued and the bot does not stand down — the visitor's next turn is those details, and it has to be answered. (An earlier build set the wait flag on this path too, which silenced the bot mid-offer and fired a second "missed" alert 90s later.) - The wait runs out (default 90s, 20–600) → the visitor's own poll expires the request, the bot apologises with those same contact details, and goes back on duty so the next message is answered normally.
Presence is measured, not declared. A person counts as reachable only when both hold: they have flipped Available for live chat on, and the live console has beaten a heartbeat within 150 seconds (AiChatAgentPresence::STALE_AFTER_SECONDS). Closing the tab therefore takes you off duty on its own — the failure mode this avoids is the one where someone forgets to flip a switch at 5pm and the bot spends the night promising a human who went home.
Three mechanics keep that honest, and each exists because of a specific way it was once wrong:
- The console's
wire:pollcarrieskeep-alive. Without it Livewire drops 95% of ticks while the tab is hidden — and a console in a background tab, the normal posture for someone waiting on the chime, read as off duty. The stale window is sized for the browser's own hidden-tab timer alignment (one wake-up a minute after five minutes hidden) and survives two missed ones. - Leaving the page beacons "away" (
POST dashboard/ai-chat/live/away,navigator.sendBeacononpagehideandlivewire:navigating), which nulls the heartbeat so the person reads as gone immediately rather than 150s later. Best-effort; the stale window is the guarantee. The Available switch is left alone — closing a tab is not a decision to go off duty. - The heartbeat write is throttled (
ChatHandoff::HEARTBEAT_WRITE_INTERVAL_SECONDS, 20s): a tick whose stored beat is still fresh is a SELECT, not an UPDATE. Installs mostly run without a queue worker on shared hosts, and a write per tick per open console was the cost that added up.
Alerts. The console badge + chime is the primary channel and always on. Email and text are both opt-in, and both deliberately fire only where the on-screen alert can't do the job: when nobody is at the keyboard at request time, or when a visitor waited and nobody came.
- Email recipients inherit the lead-notification addresses when left blank.
- Text rides the shared Twilio gateway (
SmsSender, Marketing module) already used by campaign SMS, the Inbox, and booking reminders — so the toggle is disabled with a setup reminder until that gateway exists. It is the one alert that reaches somebody who has stepped away from the desk, which is precisely the case these alerts are for, so the message is deliberately terse: one line plus a deep link into the console. Numbers take no fallback from anywhere — quietly texting a number entered for something else costs real money. - A desk line with an extension cannot receive a text. The extension is never dialled, so the message lands at the switchboard or nowhere.
CommaSeparatedPhonesrejects such a number at save time, and the field's help text says why. - Alerts are capped (
ChatHandoffNotifier): one burst per conversation per 15 minutes (PER_CONVERSATION_COOLDOWN_SECONDS), and at most 20 bursts per install per hour (MAX_ALERT_BURSTS_PER_HOUR; a burst is one email to every address plus one text to every number). The visitor controls how oftenrequest_humanruns, and the 40-message cap resets with a cleared chat or a fresh session cookie — uncapped, one visitor was a loop that texted every recipient twice a minute. A capped burst is logged; the queue and the console badge are unaffected.
The visitor-facing phone and email are typed on the settings page and default to blank. They are not inherited from the business.* facts: business.email is never auto-published, and a business's main line often isn't where a waiting customer should be sent.
The live chat console
Dashboard → AI → Live Chat (/dashboard/ai-chat/live). The queue (waiting → being handled → active in the last 2 hours), the transcript, take-over / hand-back, a reply box, and the availability switch.
It is its own page rather than a tab of the Unified Inbox because the Inbox is an optional feature that defaults off — an escalation path that only works when an unrelated module happens to be enabled is not a path. The Inbox's chat panel still works exactly as before for sites that use it.
The 10-second keep-alive poll does double duty: it refreshes the queue and it is the presence heartbeat (see the presence mechanics above).
Who can work the chat — the chat_agent grant, and the Staff rung
Being on the chat team is a per-user grant (users.chat_agent, the Live chat agent switch on Dashboard → Users), not a role. Working the chat is a duty, and a linear role ladder can't express a duty: a Manager who covers the chat on Tuesdays, an Admin who wants the alerts, and an account whose whole job is the console all need to be sayable. The grant is what User::canWorkLiveChat() reads; Manager and above clear it without the flag (they already take over chats from the Inbox), the flag is what admits anyone below, and the console route carries its own chat-agent middleware (EnsureChatAgent) rather than a role: floor for exactly that reason.
For a sub-Manager, the console is the only dashboard surface they can reach: everything else is role:manager or higher, the sidebar shows them a single Live Chat entry, and RedirectAccountHoldersFromCms sends a grant-holder to the console (not the account hub, which is a customer surface) if they land anywhere else — including after login, since fortify.home is /dashboard. The switch is only rendered while the AI Chat feature is on, and a save made while it's off leaves the stored grant alone rather than silently stripping one the admin couldn't see.
Role::Staff (20, between Standard and Manager) is the rung that goes with it: an employee whose dashboard access is exactly the sum of their grants. The rung itself opens nothing — a Staff account with no grant falls through to the account hub like a customer — but it lets the CMS tell the site's own people from account holders: role: staff visibility rules show a row to employees and not to customers, and the Users list labels them. It replaced the short-lived Agent rung from 1.4.38 (same int, name remapped by 2026_08_28_000100_rename_agent_role_to_staff) once it was clear the chat ability belonged on the user, not the ladder; the content-type grant — "may manage only the Job Listings type" — is the same shape on the same rung (see Custom content types → Scoped editors).
Two things to know before adding another role:
- The backing ints are spaced by ten (Standard 10, Staff 20, Manager 30, Admin 40, Super 50) so the next role can take a gap instead of a renumber. They are persisted as a search ACL in
page_search_entries.required_roleandembeddings.required_role, so a renumber needs a data migration in the same release or staff-only page copy silently leaks into search. That is also whyBackupService::restoreSnapshot()runsmigrateafter restoring the database: an older snapshot lands on today's code with the old ints, and the remap has to be re-applied before anyone searches. - Role names are a storage contract too.
RoleCaststores the lowercase case name and silently demotes an unrecognised one to Standard, so a rename is a data migration (widen theusers.roleenum, remap, narrow) in the same release.VisibilityResolver::roleRank()and the page-searchroleFromMiddleware()map both derive from the enum; the JS twinROLE_RANKinresources/js/visibility.jsis a literal —tests/Unit/VisibilityRoleRankTestfails if it disagrees. users.roleis a DB enum, so a new case is unassignable until a migration widens the column. Nothing fails at deploy time; it surfaces the first time an admin tries to use the tier.
What the dashboard gets
Dashboard → AI Chat (Manager+): the conversation list (last activity, language, message count, first-message preview) with a full transcript modal and delete; plus the Unanswered questions tab described above. Settings (Admin): retrieval toggles, action-tool toggles, follow-up suggestion behavior, additional context + custom instructions fed into the system prompt, the proactive prompt, widget title/welcome/theme/color, retention days, and suggested privacy-policy wording with one-click copy.
Which provider answers
The chat runs under the chat task, so Settings → AI → Per-Task Overrides → Chat assistant picks its provider and model independently of everything else. Leave it blank and the chat follows the main text provider, exactly as before; set it to OpenAI (or Google/DeepSeek) and only the visitor-facing chat moves — the editor assistant, translations, alt text, and site generation stay on the main provider. The override's provider must have its own key on Settings → API Keys; if it doesn't, the widget hides itself rather than failing mid-conversation (AiTextService::providerConfigured(TASK_CHAT) gates both the layout and the slim-runtime path).
Useful for cost control — a cheap fast model for high-volume visitor chat, a stronger one for authoring — and for keeping chat traffic with a different vendor than the rest of the site.
Privacy & data
- Conversations tie to the existing session cookie (already classified necessary) — no new cookies, no consent gating needed for the widget itself. The proactive nudge writes its suppression stamps to local/session storage only.
- Messages are processed by the configured third-party AI provider; the suggested privacy wording (and the Smart Privacy Policy generator) disclose this.
ai-chat:prune(daily LazyCron) deletes conversations idle pastai_chat.retention_days(default 90; 0 keeps forever).
Where things live
| Piece | Path |
|---|---|
| Agent loop + unanswered signals | app/Features/AiChatBot/Support/ChatAgent.php |
| Tool registry + execution | app/Features/AiChatBot/Support/ChatToolbox.php |
| Meaning-based retrieval behind the search tools | app/Support/Ai/SiteKnowledge.php (retrieve()) — see semantic-search.md |
| Unanswered phrase heuristic | app/Features/AiChatBot/Support/ChatResolution.php |
| Unresolved lead capture | app/Features/AiChatBot/Support/ChatLeadCapture.php |
| Lead notification email | app/Features/AiChatBot/Support/ChatLeadNotifier.php + Mail/ChatLeadMail.php |
| Handoff decision + presence + queue | app/Features/AiChatBot/Support/ChatHandoff.php |
| Handoff alerts (email + text) | app/Features/AiChatBot/Support/ChatHandoffNotifier.php + Mail/ChatHandoffMail.php |
| Live chat console | app/Features/AiChatBot/resources/views/dashboard/⚡live.blade.php |
| Public widget (lazy island) | app/Features/AiChatBot/resources/views/widget/⚡chat.blade.php |
| Dashboard viewer + unanswered report | app/Features/AiChatBot/resources/views/dashboard/⚡index.blade.php |
| Settings page | app/Features/AiChatBot/resources/views/dashboard/⚡settings.blade.php |
| Proactive trigger runtime | resources/js/ai-chat-nudge.js |
| Models | AiChatConversation (uuid, language, last_activity_at, lead_captured_at, taken_over_at, escalation_requested_at), AiChatMessage (role, content, unanswered), AiChatAgentPresence (available, last_heartbeat_at) |