An AI agent in one script tag

Freddy is the support agent now living in the bottom-right corner of this page. It's an LLM wired to a knowledge-base search tool, a way for the page it's embedded on to hand it more tools of its own, and a flow-control layer that can pin its steps into a fixed order when prompting alone isn't reliable enough. All of that ships as one script tag and one custom element, because none of it matters if a real page can't actually drop it in.

This post covers the widget half of the repo (apps/chatbot), not the operator dashboard next to it that manages conversations and the knowledge base. That's a different piece of software with a different set of problems, and it's real, live at dashboard.konpeeyush.me if you want to see every one of these conversations land on the other side.

The Freddy widget open on this site, answering a visitor's question about Peeyush's projects and writing.
Freddy, configured with this site's own persona, answering a question about the projects above.
The Freddy operator dashboard's Conversations view, showing a visitor's session, transcript, device and location alongside a list of recent conversations.
The other side: every conversation above lands here, unresolved until someone closes it.

Grounded in the site's own docs

Everything Freddy says about the product it's embedded in comes from a searchKnowledge tool the server holds itself, backed by an index crawled from that site's own docs. The system prompt requires the tool be called before anything about pricing, plans or policies gets answered, including things the model already seems confident about. What a model remembers about a product is usually a different version of it than the one actually sitting in front of it. Answers cite the passages that supported them. If the search comes back empty, Freddy says the docs don't cover it instead of filling the gap from memory.

Tools that run in the visitor's browser

The more interesting part of the wire contract is what the server doesn't hold. Every tool beyond that built-in search is registered by the host page, at runtime, with its implementation living in that page's own JavaScript:

FreddyChat.registerTool({
  name: "getCartTotal",
  description: "The total value of the visitor's cart right now.",
  inputSchema: { type: "object", properties: {} },
  handler: () => ({ total: cart.total, items: cart.count }),
});

The model emits a tool call, the widget runs handler in the browser, and the result goes back into the same request. The server never sees cart, and it never gets to. It's a deliberate trade: a page-defined tool can read anything the page can (the cart, the signed-in user, values in the DOM), which is also exactly what makes getCartTotal possible without Freddy's backend needing an API key into somebody else's e-commerce platform. The definition is plain JSON Schema, sent fresh with every request; the server holds no copy of it between turns.

Sequences pin an order that prompting can't

A tool call happening at all is still up to the model. Some flows need to happen in a fixed order every time (asking for an email before creating a lead, say), not "usually, if the model remembers to." A sequence compiles a flow into steps, and the runtime restricts the model to only the current step's tool while one is active, with toolChoice: "required" set server side so it can't just answer in prose and skip it:

FreddyChat.registerSequence({
  id: "demoRequest",
  label: "Demo request",
  trigger: "Use when someone asks for a demo or a callback.",
  steps: [
    { kind: "ask", field: "company", prompt: "which company they are with" },
    { kind: "tool", tool: "createLead", inputFrom: { company: "$company" } },
  ],
});

FreddyChat.onSequence reports a flow starting, advancing, and ending. deliverSequences queues completed ones to an endpoint in the visitor's own browser storage, retrying across page loads until the request actually lands. A closed tab mid-send doesn't lose the lead; it just resends it next visit.

One script tag, one element

All three of the above (search, page-defined tools, sequences) reach the browser as one script tag and one custom element:

<script src="https://chatbot.konpeeyush.me/widget.js"></script>
<freddy-chat mode="floating" position="bottom-right" theme="light"></freddy-chat>

widget.js is a single IIFE with React bundled in rather than externalised. The host page isn't expected to have React, or anything else, so nothing about the widget can depend on it having React. The script's only job on load is customElements.define("freddy-chat", ...); everything else is attributes read off the element: mode, position, theme, api-url, trigger, default-open. There's also a tag-manager form for pages that can only paste a <script> and nothing else: a data-auto attribute makes the script create its own host element and append it to <body>:

<script src="widget.js" data-auto data-mode="floating"></script>

Most attribute changes at runtime (a position flip, a trigger swap) can't just patch props in place, because a closed shadow root can't be detached and reattached to a new tree shape. So attributeChangedCallback does something a little unusual: it builds a whole replacement <freddy-chat> element, copies every attribute across, and swaps it in with replaceWith. theme is the one exception, handled in place, because a colour change doesn't need a different tree at all:

attributeChangedCallback(name, prev, next) {
  if (prev === next || !this.#widget || !this.#config) return
 
  // Theme swaps in place; everything else changes the tree shape, so remount.
  if (name === "theme") {
    this.#config = readConfig(this)
    this.#widget.setTheme(this.#config.theme)
    return
  }
 
  // A shadow root cannot be detached, so a remount means a fresh host.
  const replacement = document.createElement(TAG_NAME)
  for (const attr of Array.from(this.attributes)) {
    replacement.setAttribute(attr.name, attr.value)
  }
  this.replaceWith(replacement)
}

A shadow root the page can't reach

attachShadow({ mode: "closed" }), not "open". The difference only matters to scripts: an open root still hands back element.shadowRoot to anyone who asks, which means any other script on the host page could reach in and rewrite the widget's DOM. Closed just returns null. Styling goes in through adoptedStyleSheets instead of an injected <style> tag: one CSSStyleSheet, parsed once and shared by every instance of the widget on the page:

const shadow = host.attachShadow({ mode: "closed" })
shadow.adoptedStyleSheets = [getSheet()]

Nothing crosses the boundary in either direction: the page's CSS can't reach in past the shadow root, and the widget's CSS can't leak out onto the page around it.

The repo tests this claim literally. One of the four embed demos is called Hostile CSS, a page that sets Comic Sans, magenta dashed borders and rotated lime buttons, all of it with !important. The widget on that page is supposed to look completely ordinary. Open it and it does: the page is trying, and the widget doesn't notice.

The same openness runs the other way on the server: /chat answers Access-Control-Allow-Origin: *, on purpose, because the widget runs on domains nobody can enumerate ahead of time. An allowlist would mean asking every customer for their domain before their bot could answer a single message. There's no per-tenant auth on top of it yet either. This iteration has exactly the security model a closed shadow root and a public chat endpoint give you, and nothing more.

Three ways to sit on the page

mode is floating, inline, or fullscreen. Floating is the corner bubble everyone expects; position picks which corner. Inline drops the panel into the page's own layout instead, no trigger and no bubble, because a box the page already made room for is open by definition. Fullscreen covers the viewport, for a dedicated /support route or a mobile tap-target.

theme is light, dark, or auto. auto follows prefers-color-scheme and keeps listening for it to change underneath the page. It's also just a toggle a visitor can hit in the panel header, independent of whatever theme the host page itself is in. Nothing forces the widget to match its surroundings; the hostile demo above runs a dark page next to a light widget, and that's a supported combination, not an edge case.

The reply arrives one word at a time

Text doesn't get released to the panel in whatever chunks the model happens to emit. It's re-paced through smoothStream, segmented on word boundaries rather than a fixed timer:

experimental_transform: smoothStream({
  delayInMs: 28,
  chunking: new Intl.Segmenter(undefined, { granularity: "word" }),
}),

A timer-based split does nothing useful for Chinese, Japanese, Thai, or anything else without spaces between words. It either dumps the whole reply in one frame or slices mid-character. Intl.Segmenter actually knows where a word ends in each of those scripts, so the same 28ms cadence reads right regardless of what language the answer comes back in.

The face is borrowed

The avatar in the header isn't a static icon. It's an <AvatarCanvas> from @claykit/react, the same package from the last two posts here, running its clay finish against one JSON file, freddy.avatar.json. The bot's own state gets mapped onto that file's animations and expressions: thinking while it's chasing a tool call, searching once text is actually streaming, a pleased smile that holds for 1.8 seconds after an answer lands, an error expression if the request fails.

None of that is decoration layered on afterward. It's a small state machine that watches waiting, streaming, activeTool, and an inactivity clock, and picks the nearest expression the definition actually has for whatever's true right now.

The header icon is the one part of the panel that's on screen for the whole conversation, so it's also the cheapest place to say "I heard you" or "I'm working" without adding a separate spinner. If the avatar definition ever fails validation (malformed JSON shipped in a bad build, say), it falls back to a plain chat icon instead of taking the whole widget down with it. A face is worth having; it isn't worth a broken chat over.