# Assistable Chat Widget: Home Actions, Forms, Tickets and Handoff

A practical guide for support staff and customers. It explains what each piece
does, how to set it up, where submitted data ends up, and what to check when
something looks broken.

---

## 1. What home actions are

Home actions are the buttons on the widget's **Home screen**, the first screen
a visitor sees when they open the chat bubble. Each button is a card with a
title, an optional subtitle, and an icon.

Example: a support widget might show three cards:

- **Ask a question** opens the chat with the AI assistant
- **Report an issue** opens a short form (subject, description, priority)
- **Service status** opens your status page in a new tab

You configure them in the dashboard under **Portal > Widgets > (your widget)**,
in the "Home action cards" section. Widget templates (General, Support
helpdesk, etc.) pre-fill a sensible set; you can edit, reorder, or delete them.

Here is a complete action as stored (visible under "Advanced: edit as code"):

```json
{
  "id": "report-bug",
  "title": "Report an issue",
  "subtitle": "Found a bug? Let us know.",
  "icon": "bug",
  "mode": "form",
  "tool": "create_support_ticket",
  "tool_defaults": { "type": "bug" },
  "submit_label": "Submit",
  "success_message": "Thanks - we'll take a look.",
  "fields": [
    { "id": "subject", "label": "Subject", "type": "text", "required": true, "maps_to": "subject" },
    { "id": "description", "label": "What happened?", "type": "textarea", "required": true, "maps_to": "description" },
    { "id": "priority", "label": "Priority", "type": "priority", "maps_to": "priority" },
    { "id": "email", "label": "Your email", "type": "email", "required": true, "maps_to": "email" }
  ]
}
```

---

## 2. The three modes

Every action has a `mode` that decides what happens when a visitor taps it:

| Mode | What happens | When to use it |
|---|---|---|
| `chat` | Opens the chat. If a "Starter message" (`initial_message`) is set, it is sent to the AI immediately; otherwise the visitor's tap sends the card's **title** as the first message. | Anything conversational: questions, sales, troubleshooting with the AI. |
| `form` | Opens a form built from the action's `fields`. On submit the answers are saved and optionally forwarded (see section 4). | Structured input: bug reports, feature requests, NPS surveys, lead capture. |
| `link` | Opens `href` in a new browser tab. | Status pages, docs, booking pages, anything external. |

Notes:

- A `form` action with **zero fields** cannot be saved (the editor blocks it),
  and if one sneaks in, the widget falls back to opening the chat instead.
- A `link` action without a URL is also blocked at save time.
- A `chat` card with no starter message sends the title as the message, so
  name your cards so that reads naturally ("Ask a question", not "Card 3").

---

## 3. Building a form

In the editor, a form action gets a **Form fields** list. Each field has:

- **Label**: what the visitor sees.
- **Type**: one of:

| Type | Visitor sees |
|---|---|
| `text` | one-line text input |
| `textarea` | multi-line text input |
| `email` | email input (format is validated in the widget) |
| `select` | dropdown (you supply comma-separated options) |
| `radio` | single choice list (options) |
| `checkbox` | one yes/no checkbox |
| `rating` | 5 smiley faces (stored as 1-5) |
| `nps` | 0-10 number row with "Not at all likely / Extremely likely" labels |
| `scale` | numeric row, custom min/max (defaults 1-5) |
| `priority` | four buttons: low / normal / high / urgent |

- **Required**: the widget refuses to submit while a required field is empty
  (a required checkbox must be ticked). The server checks this again.
- **`maps_to`**: the key the answer is delivered under when it is forwarded to
  a tool or webhook.

### What `maps_to` actually does

Every field gets a random internal id like `f-a3k9` when you add it. The raw
answers are always stored under those ids. But when the answers are forwarded
to a destination (a ticket, GHL, a webhook), each answer needs a meaningful
key. That key is chosen like this:

1. If `maps_to` is set, use it.
2. Otherwise the **label** is slugified (lowercased, non-alphanumerics become
   `_`). If the slug is one of `email`, `phone`, `first_name`, `last_name`,
   `name`, that slug is used. So a field labeled just "Email" works with no
   `maps_to` at all.
3. Otherwise, if the field type is `email`, the key is `email`.
4. Otherwise the random field id is used, which no destination understands.

**Rule of thumb: set `maps_to` explicitly for any field a tool needs.** The
editor shows the inferred key as the input placeholder (`Saved as "..."`), so
you can see when inference will do the right thing. You must set it explicitly
whenever the label doesn't slugify to what the tool expects. For example, a
field labeled "Work email address" must have `maps_to: "email"`; a ticket
subject field labeled "What's this about?" must have `maps_to: "subject"`.

Keys each tool looks for:

- Tickets: `subject`, `description` (or `body`), `priority`, plus
  email under `email`/`requester_email`/`visitor_email` and name under
  `name`/`requester_name`/`visitor_name`/`first_name`.
- Feedback: `message`/`description`/`comment`/`title` (the comment),
  `type`, `severity`, plus the email/name keys above. The rating comes from the
  rating/nps/scale field automatically.
- GHL: `email`, `phone`, `first_name`, `last_name`, `name`, `tag`. Anything
  else is added to the contact as a note.
- Stripe lookups: `email`.
- KB search: `query`.

`tool_defaults` on the action is merged underneath the mapped answers. That is
how the editor stores things like the feedback `type`, the GHL `tag`, and the
per-form `webhook_url` without asking the visitor for them.

---

## 4. Where the answers go

**The core guarantee: every form submission is saved on Assistable's side,
always.** Each submit writes a `widgetFormSubmission` row (widget, action,
answers, rating, contact email/name), and, whenever there is anything a human
would read, also creates a conversation in your Inbox with the answers
formatted as a message. This happens **before** any forwarding, and it happens
whether or not a tool is set, whether or not any integration is connected, and
even when the forward fails. Integrations are optional destinations, never
preconditions.

The optional `tool` on the action decides where a **copy** of the answers is
sent. These are the tools the server accepts (anything else is rejected with
`tool_not_allowed`):

| Tool | What it does | Needs configured | If the destination is missing/broken |
|---|---|---|---|
| *(none, "Just collect the answers")* | Saves the answers and puts them in your Inbox as a conversation. | Nothing. | n/a, nothing to break. |
| `create_ticket` / `create_support_ticket` | Creates a support ticket and routes it: **Zendesk, then Gleap, then in-app inbox** (first connected one wins). Both names behave identically. See section 5. | Nothing for in-app. Zendesk or Gleap integration if you want it there. | Falls back down the chain; in-app always works. If ticketing is disabled for the widget, nothing is filed and the response says `ticketing_disabled`. |
| `submit_feedback` | Writes a `widgetFeedback` row (kind, rating, scale, comment). If a Gleap integration exists, mirrors it to Gleap in the background. | Nothing. Gleap optional. | Feedback row is still saved; a Gleap mirror failure is recorded on the row's `deliveries` field and logged. The visitor never sees an error. |
| `search_kb` | Searches the knowledge bases attached to the widget's assistant and returns matching passages. A lookup: nothing is stored. | An assistant with knowledge bases. | Returns empty passages. **Caveat: the current form UI does not display the results** (see Rough edges), so prefer `chat` mode for KB questions. |
| `get_subscription_status` | Looks up the Stripe subscription for the given email. A lookup: nothing is stored. | Stripe integration on the subaccount. | Fails with `stripe_not_connected` / `subscription_not_found`; the visitor sees a generic error. Same UI caveat as `search_kb`. |
| `list_invoices` | Lists Stripe invoices for the given email. A lookup. | Stripe integration. | Same as above. |
| `ghl_contact` | Creates/updates a GHL contact from `email`/`phone`/name fields, adds the configured `tag`, and attaches any extra answers as a contact note. | GHL connection on the subaccount (location + access token), at least one field mapped to `email` or `phone`, and a tag if your GHL workflow triggers on one. | Fails with `ghl_not_connected` or `email_or_phone_required`. If the contact is created but the tag fails, the response is an error (`ghl_tag_failed`) because the tag is usually what your automation fires on; the contact still exists in GHL. The answers are still saved on our side either way. |
| `webhook` | POSTs the mapped answers to the URL in `tool_defaults.webhook_url` as `{"source":"assistable_widget_form","subAccountId":...,"submittedAt":...,"fields":{...}}`. | A public URL on port 80 or 443 that answers 2xx within 8 seconds, no redirects. `http://` is accepted as well as `https://`. Private/internal addresses are blocked. | One synchronous attempt, **no retries**. Failure is logged and reported as `delivered: false`; the answers are still saved. For guaranteed delivery use a subscription webhook instead (section 6). |

### `ok` vs `delivered`: two separate facts

The submit response reports two things on purpose:

- `ok: true` means "we kept the answers". The submission row exists (or, for
  pure lookups, the lookup ran).
- `delivered: true/false` means "the forward the action asked for actually
  happened". When `false`, a `delivery_error` code is included.

They are separate so that a dead destination (expired Zendesk token, downed
webhook receiver) never makes visitors re-type answers we already have, and so
that a plain success can't hide a dead destination. **The visitor sees the
success screen whenever `ok` is true**; a failed forward only shows up in the
response body, server logs (`widget_action_dispatch_failed`) and error
tracking. When investigating "the form worked but nothing arrived", always
check `delivered` in the network response first.

Lookups (`search_kb`, `get_subscription_status`, `list_invoices`) are the
exception: they store nothing, so their failures are returned as `ok: false`
outright.

### What appears in the Inbox

A submission creates a conversation (type `form_submit`, channel Live Chat)
containing the answers as a readable message, **except** when the submission
is nothing but a rating (for example a bare NPS score with no comment). Those
are stored in the database tables only, and currently have no dashboard
screen, so don't expect a lone NPS score to show up anywhere visible yet.
Ticket destinations are exempt from the exception: `create_ticket` and
`create_support_ticket` always open a conversation, even from a bare rating.

---

## 5. Tickets and support

### How a ticket gets created

Three paths lead into the same routing logic:

1. A **form action** with tool `create_ticket` / `create_support_ticket`.
2. The **AI assistant** during chat. When the widget's handoff is enabled, the
   AI has two tools: `create_ticket` (file a ticket, keep chatting) and
   `request_human_handoff` (file a ticket **and** pause the AI).
3. The widget's **"talk to a human"** escalation flow (including the
   after-hours form when office hours are configured).

### The three destinations

Routing tries destinations in a fixed order and stamps the result on the
ticket's `destination`:

1. **`zendesk`**: if a Zendesk integration is connected (an active integration
   named `zendesk` with a subdomain and API token or OAuth). The ticket is
   created in Zendesk with tags `assistable`, the source, and `conv-<id>`.
2. **`gleap`**: if no Zendesk (or Zendesk failed) and a Gleap integration is
   connected (integration named `gleap` with API key + project id). Filed in
   Gleap as a bug-type item.
3. **`in-app`**: the default when no helpdesk is connected (or both failed).
   The ticket lives in Assistable: a `supportTicket` row plus the conversation
   in your Inbox, retitled with the ticket subject.

Important: a Zendesk failure **falls back silently** to Gleap, then in-app. The
ticket is never lost, but it may not be where you expected. Check the
conversation's routing info or the `supportTicket.destination` value before
concluding a ticket "disappeared".

Every ticket also writes/updates a `supportTicket` database row (subject,
description, priority, status, requester, destination, external id/URL). A
retry on the same conversation **updates** the open ticket rather than filing a
duplicate; the extra details are appended to the conversation as a follow-up
message. A new ticket is opened only after the previous one is resolved.

### Connected helpdesk vs not

- **Helpdesk connected:** the ticket goes there; the Inbox conversation gets a
  cross-link (external ticket id and URL). Your team works the ticket in
  Zendesk/Gleap. The AI keeps handling the widget conversation.
- **No helpdesk:** the ticket is the Inbox conversation itself, marked as a
  support request with subject and priority. Your team replies from the Inbox.

### What handoff does

Handoff means a human takes over the conversation. It is opt-in per widget
("handoff enabled" in the widget editor; without it the AI does not even get
the handoff/ticket tools). When handoff triggers on an in-app route:

- The conversation is put to sleep (`isSleep`) with an effectively indefinite
  pause and status `escalated`; the AI stops replying.
- The widget shows the visitor that a human will take over; the session status
  flips to "human".
- Handing back from the Inbox wakes the AI again.

A plain `create_ticket` deliberately does **not** pause the AI. Only an
explicit human handoff parks the conversation.

Two handoff modes exist on the widget (`support` and `transparent`); both park
the conversation the same way, the difference is presentation in the widget.

### Who gets notified

On every routed ticket/handoff (whichever destination):

- **Slack**: if a Slack integration is active on the subaccount, a handoff
  alert is posted (Discord likewise if configured).
- **Email**: an escalation email is sent via Resend to every OWNER/ADMIN
  user of the subaccount. Silently skipped when no such user has an email or
  the email service is not configured.
- **`handoff.requested` webhook**: emitted to workspace webhook subscribers
  on the first ticket for a conversation, regardless of destination.
- A handoff started from chat additionally emits `widget.handoff.started`.

Duplicate requests on an already-routed conversation do **not** re-notify (by
design); the new content is appended to the conversation instead.

### Turning ticketing off

A widget-level switch exists in the backend: setting `ticketsEnabled: false` in
the widget's stored fields makes every ticket attempt from that widget a
refusal. Nothing is filed, nobody is notified, and the AI is told to offer an
alternative contact route. Absent means **on**. Caveat: there is currently no
dashboard control for this flag (see Rough edges), so treat it as
support-/API-only.

---

## 6. Webhooks

There are two unrelated "webhook" features. Don't mix them up:

**A. The per-form `webhook` tool** (section 4): one action posts its own
answers to one URL you put on that action. Single attempt, no retries, no
signature.

**B. Workspace webhook subscriptions**: durable, signed, retried event
delivery for your whole workspace. Configure under **Dashboard > (workspace) >
Integrations > Webhooks**: add an endpoint URL and tick the events you want.

Available events:

```
call.started            call.ended              call.failed
message.received        message.sent            handoff.requested
payment.succeeded       payment.failed
contact.created         contact.updated         appointment.booked
oauth.connected         oauth.disconnected
widget.conversation.created   widget.conversation.ended
widget.contact.identified     widget.handoff.started
widget.message.received       widget.message.sent
widget.form.submitted
```

Deliveries are signed (`X-Webhook-Signature: t=<unix>,v1=<hex hmac-sha256>`)
and retried up to 4 attempts. Every attempt is recorded and inspectable per
endpoint in the dashboard.

### `widget.form.submitted` payload

Sent on every non-lookup form submission (even when no tool is set, and even
when the tool's forward failed):

```json
{
  "event": "widget.form.submitted",
  "eventId": "widget.form.submitted-<submissionId>",
  "workspaceId": "ws_...",
  "occurredAt": "2026-08-11T09:15:00.000Z",
  "data": {
    "submissionId": "cmb...",
    "conversationId": "cmb... or null",
    "subAccountId": "...",
    "widgetId": "...",
    "actionId": "report-bug",
    "actionTitle": "Report an issue",
    "tool": "create_support_ticket",
    "values": {
      "subject": "Login broken",
      "description": "...",
      "priority": "high",
      "email": "visitor@example.com"
    }
  }
}
```

Note: `data.values` is keyed by **field id**, not by `maps_to`. It is the raw
answers as stored. (The per-form `webhook` tool is the opposite: its `fields`
object is keyed by the mapped keys.) If your field ids are random (`f-a3k9`),
either give fields meaningful ids in the JSON editor or key your consumer off
`actionId` + position.

Requirement: the subaccount must belong to a workspace. Webhook subscriptions
are workspace-level; if the subaccount has no workspace linkage the event is
silently skipped.

---

## 7. Setting it up, end to end

1. **Create the widget.** Portal > Widgets > New. Pick a template (Support
   helpdesk gives you ask/report/handoff actions out of the box). Assign an
   assistant; it powers chat, KB search, and the AI's ticket/handoff tools.
2. **Add or edit home actions.** Open the widget > "Home action cards". Start
   from a template card ("Bug report", "NPS survey", ...) or "Add blank card".
   Set title, subtitle, icon (a lucide icon name in kebab-case: `help-circle`,
   `alert-triangle`, `smile`, `bug`, `package`; unknown names silently render
   as the generic message icon), and mode.
3. **For a form: pick the tool** under "What should happen with this form?".
   "Just collect the answers" is a perfectly good choice; everything lands in
   your Inbox regardless.
4. **Configure the destination.**
   - Tickets: nothing required (in-app works out of the box). To route to
     Zendesk/Gleap, connect the integration on the subaccount first.
   - GHL: enter the tag; make sure one field maps to `email` or `phone` (the
     editor warns in red if not).
   - Webhook tool: enter the public URL (`http://` or `https://`, port 80 or 443).
   - Stripe lookups: connect Stripe; include an `email` field.
   - Check each field's `maps_to` placeholder reads as the key the destination
     expects; override where it doesn't.
5. **Enable handoff** (widget editor) if you want visitors to reach a human and
   the AI to be able to file tickets/escalate.
6. **Save.** Saving validates the actions (a form needs fields, a link needs a
   URL) and pushes the change live; embedded widgets pick it up within about
   30 seconds.
7. **Embed.** "Get code" gives you the snippet. If you restrict "Allowed
   domains", the embedding site's domain must be on the list; an empty list
   allows all domains.
8. **Test on the real page.** Open the widget, tap the action, submit the
   form. Then verify each leg: the success screen (visitor side), the Inbox
   conversation (our side), the destination (Zendesk/Gleap/GHL/webhook
   receiver), and, in the browser dev-tools network tab, that the submit
   response shows `"ok": true, "delivered": true`.

---

## 8. Troubleshooting

First diagnostic step for any form problem: open browser dev tools > Network >
submit the form > read the JSON response of `POST /api/v1/widget/actions/submit`.
The `error` / `delivery_error` code tells you which row below you are in. (The
widget shows only a generic message; the response body has the real reason.)

| Symptom | Likely cause | What to check / fix |
|---|---|---|
| Form shows "Sorry, that didn't go through" on submit | Server rejected it. The generic message hides the real error. | Read the response body. `Missing required field: X` means a required field was empty (or the live config is stale, see below). `tool_not_allowed` means the action's `tool` string is not one of the eight supported values (typo in the JSON editor). `action_not_form_mode` / `action_not_found` means the widget is running a stale cached config, or the action was deleted; wait ~1 min or re-save the widget. HTTP 403 means the embedding domain is not on the widget's Allowed domains list. HTTP 429 is the rate limit (60/min per visitor IP, 1200/min per widget). |
| Lookup form (subscription / invoices / KB) always errors or shows nothing useful | `stripe_not_connected` (no Stripe on the subaccount), `email_required` (no field maps to `email`), `subscription_not_found` (no Stripe customer for that email). Also: the form UI currently doesn't render lookup results at all. | Connect Stripe, fix `maps_to`. For a visitor-facing experience, use a `chat` action instead: the AI can run these lookups and present results. |
| Answers save (Inbox has them) but nothing reaches GHL | The forward failed after the local save. This is the `ok:true, delivered:false` case. | Response `delivery_error`: `ghl_not_connected` means the subaccount has no GHL location/token. `email_or_phone_required` means no submitted field mapped to `email` or `phone` (check the red warning in the editor). `ghl_tag_failed` means the contact was created but the tag add failed; verify the tag exists in GHL, and note your workflow trigger never fired. Also confirm the visitor actually filled the email/phone field: empty answers are dropped before mapping. |
| No `widget.form.submitted` webhook arrives | Wrong webhook concept, no subscription, or no workspace. | Are you expecting the workspace **subscription** webhook or the per-form `webhook` tool? For subscriptions: the endpoint must exist under Dashboard > Integrations > Webhooks, be active, not paused, and have `widget.form.submitted` ticked; check its delivery log (4 attempts are recorded). The subaccount must belong to a workspace. Note lookups (search/subscription/invoices forms) never emit this event. For the per-form tool: `tool_defaults.webhook_url` must be set, a public `http://` or `https://` URL on port 80 or 443, answer 2xx in under 8s, no redirect; one attempt only. Check `delivery_error` (`webhook_http_500`, `webhook_blocked: ...`, `webhook_delivery_failed`). |
| The action doesn't appear in the widget at all | Not saved, filtered out, or cached. | Did Save succeed? (A form with no fields or a link with no URL blocks the whole save with a toast.) An action missing `id` or `title` is silently dropped when served. Config is cached ~30 s in the browser/CDN and 5 min server-side; hard-refresh the page after a minute. If it still misses, confirm the embed uses the right widget id and the widget isn't archived. |
| Icons all look the same (generic chat bubble) | The icon names aren't recognized, so every card falls back to the default message icon. | Icon is a free-text field. Use a supported lucide name in kebab-case: `bug`, `lightbulb`, `smile`, `help-circle`, `alert-triangle`, `package`, `credit-card`, `calendar`, `star`, `zap`, ... Case and punctuation are forgiven (`HelpCircle` works), but a name outside the supported set silently falls back. |
| Ticket "went missing" / landed in the wrong place | Routing precedence, silent fallback, or per-widget disable. | Order is Zendesk, then Gleap, then in-app; a Zendesk API failure falls back silently. Check `supportTicket.destination` / the conversation's routing stamp. The builder label "Create support ticket (Zendesk)" is misleading: it routes to whatever is connected, and works with nothing connected. If the response was `ticketing_disabled`, the widget's `ticketsEnabled` flag is false. |
| A second ticket from the same visitor never showed up | Deduping. | One open ticket per conversation: repeat requests update the existing ticket and append the new text to the conversation; no new notification is sent. A new ticket opens only after the old one is resolved. |
| The AI never offers to create a ticket or hand off | Handoff is disabled on the widget, so the tools aren't given to the model. | Enable handoff in the widget editor. |
| Nobody got notified about a handoff | Notification channels are all opt-in. | Slack: is the integration active on the subaccount? Email: does the subaccount have an OWNER/ADMIN user with an email, and is the email service configured? Also remember duplicates don't re-notify. |
| NPS/rating responses seem to vanish | A bare rating with no comment creates no Inbox conversation (unless the destination is a support ticket); it lives only in the `widgetFormSubmission`/`widgetFeedback` tables, which have no dashboard screen yet. | Add an optional comment field so submissions create Inbox threads, subscribe to `widget.form.submitted`, or query the tables directly. |
| Edits saved in the dashboard don't show on the live site | Config caching, or a stale widget bundle on the embedding page. | Wait ~1 minute and hard-refresh. If the *feature itself* (not the config) is missing, the site may be loading an old copy of the widget script; check the script source the page embeds. |

---

## Rough edges worth fixing

Prioritised list of things in the current code that make these features
confusing or fragile. File references are to the three repos:
`BE` = assistable-buildship-replacement-be, `v2` = assistable-v2,
`widget` = chat-widget-react.

1. **Lookup tools are wired into a form UI that throws their results away.**
   `widget/src/components/widget/ActionFormView.tsx:81-94` parses only
   `ok`/`error`/`success_message` from the submit response; the `result`
   payload (KB passages, subscription, invoices) that the BE carefully returns
   (`BE packages/api/src/routes/widget-action-submit.ts:619-656,959`) is never
   rendered; the visitor just sees "Submitted". The builder even ships an
   "Order status (commerce)" template built on `list_invoices`
   (`v2 .../widgets/_components/home-actions-builder.tsx:219-239`), which
   therefore looks broken by construction. Either render `result` in the form
   view or remove the three lookup tools from `TOOL_OPTIONS` and route those
   use-cases to chat.

2. **Tool naming is inconsistent and the labels lie.** The BE accepts both
   `create_ticket` and `create_support_ticket` as the same thing
   (`BE widget-action-submit.ts:76-85,557`); the builder offers only
   `create_support_ticket`, labeled "(Zendesk)"
   (`v2 home-actions-builder.tsx:91`), while the widget *templates* emit
   `create_ticket` (`v2 src/server/api/routers/widget/templates.ts:137,216`),
   which isn't in `TOOL_OPTIONS` and renders as a raw "`create_ticket`
   (saved)" fallback item (`v2 home-actions-builder.tsx:622-627`).
   The "(Zendesk)" and "(Gleap)" labels are wrong: routing is
   Zendesk, then Gleap, then in-app, with in-app always available
   (`BE packages/api/src/services/tickets/route.ts:935-1072`), and feedback is
   local-first with Gleap as an optional mirror
   (`BE widget-action-submit.ts:585-616`). Pick one tool string and fix the
   labels to say where things actually go.

3. **`ticketsEnabled` has no writer, and the editor save wipes unknown field
   keys.** The BE reads `fields.ticketsEnabled` with a comment claiming it is
   "set in the widget builder" (`BE services/tickets/route.ts:174-180`), but
   nothing in v2 writes it (only BE tests reference it). Worse, the widget
   editor rebuilds the whole `fields` JSON from its known keys on every save
   (`v2 .../widgets/[id]/page.tsx:1187-1212`) and the router replaces the
   column wholesale with `fields: z.any()`
   (`v2 src/server/api/routers/widget/index.ts:396,461-467`), so a
   `ticketsEnabled: false` set via API/support, or any other key the editor
   doesn't know (`newsHeading`, `helpSearchPlaceholder`, etc., which
   `BE widget-config.ts:199-215` serves), is silently deleted the next time
   anyone clicks Save. Merge instead of replace, and give the flag a toggle.

4. **Failed deliveries are invisible to operators.** When a forward fails the
   visitor still sees success, and the only traces are a log line plus an
   error-tracker event (`BE widget-action-submit.ts:900-931,939-961`); the
   widget ignores `delivered`/`delivery_error` entirely
   (`widget ActionFormView.tsx:81-94`). There is no dashboard surface for
   `widgetFormSubmission` (BE's own comment: "WidgetFormSubmission has no UI
   yet, so a row on its own is invisible", `BE widget-action-submit.ts:369-372`),
   none for `supportTicket` (no v2 reads of that model), and the
   `deliveries` JSON on `widgetFeedback`/`supportTicket` is written but never
   read. Bare-rating NPS submissions create no conversation either
   (`BE widget-action-submit.ts:355-366`), so a customer running the NPS
   template collects scores nobody can see. A minimal "Submissions" tab per
   widget with a delivered/failed column would close most of the "it looks
   broken" complaints.

5. **Two unrelated features are both called "webhook", with different payload
   shapes and different guarantees.** The per-form `webhook` tool posts
   `{source, subAccountId, submittedAt, fields}` keyed by `maps_to`, one
   attempt, unsigned, no retries (`BE widget-action-submit.ts:733-773`); the
   subscription event `widget.form.submitted` posts an envelope with
   `data.values` keyed by raw field id, signed, 4 retries
   (`BE widget-action-submit.ts:485-517`,
   `BE queue/handlers/webhook-fanout.worker.ts:62-68`). Customers cannot be
   expected to keep these straight. At minimum rename the tool option ("POST
   answers to a URL (no retries)") and unify the key scheme; `values` keyed by
   field id is useless when ids are random `f-a3k9` slugs.

6. **The widget never sends `visitor_session_id` on form submit**
   (`widget ActionFormView.tsx:72-79`), even though the endpoint accepts it and
   stores `visitorId` (`BE widget-action-submit.ts:833`). Every form
   submission therefore has a null visitor linkage, defeating the
   contact/visitor attribution the schema was built for. And the server's
   specific error text (e.g. "Missing required field: Email") is discarded in
   favor of a generic message (`widget ActionFormView.tsx:86-88`).

7. **The icon field is free text with a silent fallback.** Any unrecognized
   name renders as the default message icon
   (`widget src/lib/icons.tsx:280-283`), the builder placeholder suggests
   non-kebab examples ("e.g. message, star, package",
   `v2 home-actions-builder.tsx:565`), and there is no picker or validation.
   This is the direct cause of "all the icons look the same". The icon also
   renders small, right-aligned, and muted (`widget HomeView.tsx:478-486`), so
   even correct icons barely read.

8. **Client-only validation of home actions.** The zod schemas that enforce
   "form needs fields" / "link needs URL" live in the editor page
   (`v2 .../widgets/[id]/page.tsx:288-342`); the server mutation accepts
   `fields: z.any()`. Anything writing via API (templates, scripts, future
   editors) can store actions the widget will silently drop or misroute
   (`BE widget-config.ts:612-655` drops items without `id`/`title` and coerces
   a bad `mode` to `chat`). Move the schema server-side.

9. **Editor saves race the config cache.** Cache invalidation is a best-effort
   3-second fetch from v2 to the BE (`v2 widget/index.ts:13-45`); when it fails
   the old config lives for up to 5 minutes server-side plus 30 s HTTP
   (`BE widget-config.ts:5-10`), and a deleted action can then 404 as
   `action_not_found` on submit for visitors mid-session. Low severity but a
   recurring "my change didn't take" support theme.
