Krispy AI
reference

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):

ruleresponse
theme.avatar over 48KB413 { 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 chars413 { error: "kb_sources_too_large" }
more than 8 popups413 { error: "too_many_popups" }
a popup text over 500 chars413 { error: "popup_text_too_large" }
a popup selector (near selector / cancelOnClick) over 200 chars413 { error: "popup_selector_too_large" }
more than 5 script.opening entries413 { error: "too_many_opening" }
more than 4 script.starters entries413 { error: "too_many_starters" }
persona + script combined free text over 8K chars413 { error: "persona_script_too_large" }

KV keys

keyvalue
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)

On this page