lead forms + connectors
define data-driven forms the bot can raise, and wire where captured leads go.
lead forms + connectors
a form is a data-driven card the bot can raise with [!FORM:<id>] (booking, quote,
demo). connectors decide where a captured lead goes. both live in your tenant config in
KV โ no code, no redeploy.
define a form
a form is a FormSpec: an id (what [!FORM:<id>] references), a title, an ordered list
of fields, and optionally connectorIds to scope which connectors receive its leads
(default = all configured).
{
"forms": [
{
"id": "book",
"title": "book a call",
"fields": [
{ "name": "name", "label": "your name", "type": "text", "required": true },
{ "name": "email", "label": "email", "type": "email", "required": true },
{
"name": "budget",
"label": "rough budget",
"type": "select",
"options": ["<5k", "5โ20k", "20k+"]
},
{ "name": "notes", "label": "anything else", "type": "textarea" }
],
"connectorIds": ["tg-main", "sales-email"]
}
]
}field type is one of text | email | tel | textarea | select (select uses
options). the widget renders the form entirely with createElement/textContent (never
innerHTML), so field labels/options are XSS-safe.
the form renders as a card inside the message log โ it scrolls with the conversation
like a bubble (chat continues past it) instead of pinning between the log and the composer.
on successful delivery it collapses in place to a compact one-line record: successText if set,
else a built-in "Thanks โ we'll be in touch." If email delivery fails, the form keeps the
visitor's values and offers a retry. Up to the ten most recent chat messages accompany the
lead email, so the visitor need not repeat the question.
teach the bot to raise it
the bot only offers a form if it knows the form exists. the server injects the list of your
forms into the system prompt automatically (<id> (<title>)), instructing the model to
append [!FORM:<id>] when a visitor is ready for a concrete next step it can't complete in
chat. you don't wire this by hand โ configuring a form is enough.
connectors โ where a lead goes
{
"connectors": [
{ "id": "tg-main", "type": "telegram" },
{ "id": "sales-email", "type": "email", "toAddress": "sales@you.com" },
{ "id": "wa", "type": "whatsapp", "phone": "15551234567" },
{ "id": "ig", "type": "instagram", "profileUrl": "https://instagram.com/you" }
]
}| type | fields | behavior |
|---|---|---|
telegram | โ (uses the top-level bot token) | the lead is posted into the visitor's topic |
email | toAddress | rendered + sent via Resend (silent no-op without RESEND_API_KEY) |
whatsapp | phone (E.164 digits, no +) | a wa.me CTA link in the widget; also a "Reply on WhatsApp" button in the lead email |
instagram | profileUrl | a profile-link CTA with the filled Instagram glyph and a comfortable mobile tap target |
telegram and email are delivery channels โ the lead is sent for you. whatsapp and
instagram are CTA-only: the widget renders them as links for the visitor to tap;
nothing is delivered server-side for them, and they never leave the server as data.
An operator can send an existing form or Instagram CTA while talking with a visitor.
The app lists the forms and visitor-facing Instagram connectors configured for that
conversation's site. Selecting one places a card in the chat transcript. The form
uses the same /api/lead delivery path as a bot-offered form, and the Instagram
button uses the connector's configured HTTPS profile URL. Cards survive reconnects.
Lead emails show labeled fields and the recent conversation. To include Buttr
beside the Krispy wordmark, set the edge Worker's EMAIL_ASSET_ORIGIN to a public
HTTPS host serving /brand/buttr-chill.png. Email clients that block images still
show the text wordmark; unset or non-HTTPS origins omit the image.
While a conversation waits for a team member, the widget offers a compact
"Talk to the team" card using only the site's configured forms and visitor-facing
CTAs. A form choice expands the existing form in the chat transcript; /api/lead
still receives the recent conversation. A visitor can also request an audio call
when the site's call settings allow it and the call status confirms availability.
Unconfigured choices stay hidden. The pending choice card closes when a team
member takes over or the AI resumes; an expanded form keeps the visitor's input.
the two capture paths
[!FORM:<id>]โ the widget renders yourFormSpec; on submit itPOSTs to/api/lead, which fans the values out to the scoped connectors.[!HANDOFF]with no form โ the widget offers configured contact choices while the team is pending. It does not create a default contact form or delivery route./api/contactremains as a back-compat shim for already-deployed widgets.
write the config
push it with the CLI (it merges โ unset fields are preserved):
KRISPY_API=https://krispy-edge.YOU.workers.dev TENANT_SYNC_SECRET=... \
bun packages/cli/src/index.ts set-kbase ./kbase.mdset-kbase writes the systemPrompt; for forms/connectors/theme you POST the same
shape to /api/tenant/config (see reference โ edge routes).