TenantConfig reference
the KV-backed config shape that drives a tenant's bot, forms, connectors, and theme.
TenantConfig
everything a tenant needs, keyed by tenantId (default "self") in KV under tenant:<id>.
for "self", botToken/chatId come from Worker secrets and the rest from KV; any
other tenant reads the whole thing from KV.
interface TenantConfig {
botToken?: string; // optional Telegram bot token (BotFather)
chatId?: string; // optional target supergroup id WITH topics, e.g. -1001234567890
systemPrompt?: string; // optional system-prompt override
model?: string; // optional model override
onboardingStep?: number; // onboarding progress (optional; mirrors cloud)
onboardingComplete?: boolean;
visitorIdentity?: {
enabled?: boolean; // default false; a name prompt never blocks chat
prompt?: string; // English, up to 120 characters
promptHe?: string; // Hebrew, up to 120 characters
afterMessages?: number; // 1โ5 visitor messages; default 1
};
forms?: FormSpec[]; // lead forms
connectors?: Connector[]; // delivery / CTA connectors
theme?: WidgetTheme; // widget appearance
persona?: PersonaSpec; // bot voice + style rules โ SERVER-ONLY, folded into the prompt, NEVER public
script?: ConversationScript; // scripted opening sequence + starter chips (projected to the widget)
popups?: PopupSpec[]; // proactive popup engine (theme.popupText is sugar for one timer popup)
kbSources?: KbSource[]; // per-doc knowledge registry, assembled into the prompt at chat time
kbVersion?: number; // bumped on any kbSources write (retrieval cache key)
operators?: Operator[]; // humans to @mention on handoff (auto-learned; NEVER public)
callSettings?: {
enabled?: boolean; // default false; enables operator-initiated audio calls
visitorRequestsEnabled?: boolean; // default false; visitor may request a call
visitorRequestTrigger?: "after_handoff" | "always"; // default after_handoff
notifyOnVisitorRequest?: boolean; // default true; push to registered operator devices
};
}For Cloud tenants, getTenant() treats the stored config as usable without Telegram:
the prompt, theme, forms, and Buttr handoff remain active. botToken and chatId only
enable Telegram topic mirroring and screenshot forwarding. The public widget projection
exposes capabilities.attachments as a boolean so app-only tenants never offer a dead upload path.
For the self-hosted "self" tenant, the existing environment-secret setup still requires
both Telegram values before getTenant() assembles the self-host config.
FormSpec
interface FormSpec {
id: string; // referenced by [!FORM:<id>]
title: string; // card heading, e.g. "book a call"
fields: FormField[];
connectorIds?: string[]; // which connectors receive this lead; default = ALL configured
successText?: string; // shown in the collapsed card after submit; widget has a default
}
interface FormField {
name: string; // machine key, e.g. "budget"
label: string; // visitor-facing
type: "text" | "email" | "tel" | "textarea" | "select";
required?: boolean;
options?: string[]; // select only
}Connector
type ConnectorType =
"email" | "telegram" | "whatsapp" | "phone" | "instagram" | "facebook" | "tiktok" | "link";
// CTA fields โ a connector doubles as a visitor-facing CTA card. Inert on the
// delivery-only types (email/telegram); the public projection fills per-type defaults.
interface CtaFields {
cta?: boolean; // default true for CTA-capable types โ false keeps it delivery-only
label?: string; // button label; projection fills a per-type default when unset
caption?: string; // small text above the button ("or keep chatting here")
showAfterMs?: number; // per-CTA stagger after the FIRST visitor message. Default 0
}
interface Connector extends CtaFields {
id: string;
type: ConnectorType;
toAddress?: string; // email
phone?: string; // whatsapp/phone โ digits, no "+" (server builds wa.me/tel hrefs)
profileUrl?: string; // instagram (https)
url?: string; // facebook/tiktok/link (https)
// telegram uses the top-level botToken/chatId โ no per-connector creds
}telegram + email are delivered server-side; whatsapp, phone, instagram,
facebook, tiktok, and link render as visitor-facing CTA links. Set cta: false to
keep an otherwise-CTA connector delivery-only (e.g. a whatsapp wired into a form's
connectorIds but never shown as a chat CTA). The public projection computes the href
server-side (wa.me/โฆ, tel:+โฆ, or the https url) and supplies a default label per type.
see lead forms + connectors.
Operator
interface Operator {
id: number; // Telegram user id โ enough for a text_mention (no public @username needed)
name?: string; // display name (first_name), used as the mention label when no username
username?: string; // public @username (preferred โ a plain @mention, no entity)
}the humans the bot @mentions on handoff (the "quiet ops" workflow โ mute the group,
get pinged only when a person is needed). Auto-learned: whoever replies (non-bot) in a
managed topic is upserted into this list, capped at 10 (FIFO). Zero-config โ no need to set
it by hand, though you can via POST /api/tenant/config. With an empty list the handoff
alert still fires, just without a mention.
operators is secret-adjacent (Telegram user ids) and is never exposed to the public
GET /api/widget/config. see connect Telegram and
security.
WidgetTheme
interface WidgetTheme {
launcherStyle?: "circle" | "pill"; // default circle; pill shows a label while closed
launcherLabel?: string; // pill text, displayed up to 24 chars; defaults to the widget title
primaryColor?: string; // visitor bubble + actions + header halo. Default gold #e39a2b
launcherColor?: string; // optional badge fill; unset/"transparent" keeps Buttr floating
glowColor?: string; // hex โ launcher glow/sparkle/pulse tint. Unset = no glow layer at all
position?: "br" | "bl"; // bottom-right | bottom-left. Default "br"
avatar?: string; // "buttr" (inline default) | "none" | https URL | data:image/โฆ URI (โค48KB)
greeting?: string; // first bot bubble on open
headerTitle?: string; // header text (supersedes data-title)
tagline?: string; // header sub-line ("usually replies in minutes")
radius?: number; // panel corner radius px, clamped 0โ20. Default 20
font?: string; // CSS font-family stack
sound?: boolean; // notification ding. Default true
sparkle?: boolean; // idle sparkle loop on the launcher. Default false
direction?: "ltr" | "rtl"; // flips bubble corners + input dir. Default "ltr"
popupText?: string; // proactive teaser-popup copy. Unset = popup off
timing?: WidgetTiming;
}
interface WidgetTiming {
// all ms; the widget clamps 0โ300000. Each takes effect only when its feature is on.
launcherDelayMs?: number; // default 0 = no entrance delay
sparkleAfterMs?: number; // default 10000; inert unless sparkle
popupDelayMs?: number; // default 8000; inert unless popupText
popupCooldownHrs?: number; // default 24; inert unless popupText
autoOpenMs?: number; // default 0 = never โ auto-open after inbound msg while closed
}every new knob defaults to unset/off โ a tenant that configures nothing gets today's neutral widget, unchanged.
only the WidgetTheme fields above (plus capabilities, ctas, script, and popups
below) are exposed to the public GET /api/widget/config; the projection is a literal
whitelist (never a spread of the config), so botToken/chatId/systemPrompt/persona
are structurally excluded. capabilities contains booleans only. see security.
PersonaSpec
interface PersonaSpec {
toneOfVoice?: string; // free text: "warm, playful boulangerie owner; short sentences"
styleRules?: string[]; // discrete do/don't rules: "never discuss competitors"
}how the bot speaks. Folded into the system prompt by buildSystemPrompt (a ## Voice
block + a ## Style rules bullet list), so it sits inside the prompt-leak guard scope โ
a bot reciting its own tone verbatim counts as a leak. Server-only: persona is
structurally excluded from the public GET /api/widget/config. All fields unset โ the bot
speaks exactly as today.
ConversationScript
interface ConversationScript {
opening?: string[]; // scripted bot-bubble sequence on panel open (projection caps at 5)
// opening[0] supersedes theme.greeting; greeting alone == opening of length 1
starters?: string[]; // suggested-question chips on a FRESH conversation (projection caps at 4)
// click sends the text as the visitor's message
}how a conversation starts. Projected to the widget. Unset โ today's single-greeting behavior, unchanged.
PopupSpec
interface PopupSpec {
trigger:
| { kind: "timer"; delayMs?: number } // shown delayMs after load (default 8000)
| { kind: "near"; selector: string; dwellMs?: number; threshold?: number }; // 8000 / 0.3
text: string; // teaser card copy
persist?: boolean; // default true โ cooldownHours applies; false = resets every load
cooldownHours?: number; // default 24; per-popup localStorage key (source ?? index)
cancelOnClick?: string; // selector โ visitor clicked the thing itself โ dismiss
source?: string; // origin label ("popup_pricing") โ session context / handoff / lead meta
}one engine for both timed and section-proximity teaser popups. theme.popupText is sugar
for a single { trigger: { kind: "timer" }, text } entry (with timing.popupDelayMs /
popupCooldownHrs as its defaults). No popups and no popupText โ nothing ever shows.
KbSource + relearning
interface KbSource {
id: string;
name: string; // human label / doc title, e.g. "Refund policy"
text: string; // the knowledge body
updatedAt: number;
}
interface KbSuggestion {
id: string;
question: string; // what the bot couldn't answer
answer: string; // what the human did
createdAt: number;
}kbSources is a per-doc knowledge registry assembled into the system prompt at chat time
as a ## Knowledge block (between the persona and the forms/guardrail contracts) โ
runtime assembly, not compile-on-save, so self-hosters get the identical feature and no
second writer can race the config field. The knowledge block is deliberately excluded from
the detectPromptLeak scope: a bot quoting its own KB verbatim is correctness, not a leak.
Unset โ the prompt is byte-for-byte what it is today. kbVersion bumps on any kbSources
write (approve path) โ the cache key the size-gated retrieval design expects.
Relearning. When a handed-off session hands back to the AI (operator resolves it, or the
silence alarm fires) and it had โฅ1 operator message, the SessionDO makes one Workers-AI
call to extract at most one QโA the human answered that the bot couldn't, and appends it as a
KbSuggestion. It is off by default โ a session only relearns when the tenant is already
using the knowledge base (โฅ1 kbSource); a tenant with no kbSources incurs zero AI cost on
handback and behaves exactly as today. When it does run, the suggestion lands as a
KbSuggestion under its own per-site KV key suggestions:<ns(t,s)> (cap 20 FIFO,
normalized-question dedup against pending suggestions and approved kbSources). A separate
key means the machine-initiated background write can never race a human dashboard save of the
config blob. Suggestions are never projected to the public widget config. Surface them via
the secret-authed GET /api/tenant/kb-suggestions; a human approves (kb-approve โ moves it
into kbSources + bumps kbVersion) or dismisses (kb-dismiss) โ the bot never self-modifies
its KB.
write caps
POST /api/tenant/config hard-rejects out-of-bounds configs at the trust boundary
(nothing invalid ever reaches KV):
| rule | response |
|---|---|
theme.avatar over 48KB | 413 { error: "avatar_too_large" } |
theme.avatar not "buttr" / "none" / https://โฆ / data:image/(png|webp|jpeg);base64,โฆ | 400 { error: "avatar_scheme_invalid" } |
a connector CTA url (profileUrl/url) not https:// | 400 { error: "cta_url_not_https" } |
kbSources total text over 100K chars | 413 { error: "kb_sources_too_large" } |
more than 8 popups | 413 { error: "too_many_popups" } |
a popup text over 500 chars | 413 { error: "popup_text_too_large" } |
a popup selector (near selector / cancelOnClick) over 200 chars | 413 { error: "popup_selector_too_large" } |
more than 5 script.opening entries | 413 { error: "too_many_opening" } |
more than 4 script.starters entries | 413 { error: "too_many_starters" } |
persona + script combined free text over 8K chars | 413 { error: "persona_script_too_large" } |
KV keys
| key | value |
|---|---|
tenant:<id> | the TenantConfig JSON blob |
session:<tenant>:<sessionId> | the Telegram thread id for that session |
thread:<tenant>:<threadId> | the session id for that thread |
handoff:<tenant>:<sessionId> | Telegram-independent operator-inbox discovery; the SessionDO owns live handoff state |
usage:<tenant>:<yyyymm>:<kind> | monthly usage counter โ kind โ ai | handoff | tokens |
entitlement:<tenant> | optional billing snapshot (unused in self-host) |
suggestions:<ns(t,s)> | pending relearning KbSuggestion[] (per-site, cap 20 FIFO; own key, never the config blob) |