Cheela Labs
GUIDES

Embed chat

Three ways to put a chat interface on a page, from a React component tree down to a single script tag. All three call the same endpoint with the same key.

Use the public key

Every embed below authenticates with the runtime’s public key — the one starting ch_pk_. It is embeddable by design and can do exactly one thing: execute.

Never the deploy key

ch_sk_… authorises cheela deploy. Putting it in page source lets anyone overwrite your runtime’s capability set. The two keys exist separately so that the page can be safe.

React

Terminal
npm install @cheela/ui
app/page.tsx
import { Chat, CheelaProvider } from "@cheela/ui";

export default function Page() {
  return (
    <div style={{ height: 480 }}>
      <CheelaProvider apiKey={process.env.NEXT_PUBLIC_CHEELA_PUBLIC_KEY!}>
        <Chat placeholder="Ask me anything..." />
      </CheelaProvider>
    </div>
  );
}

In the Next.js App Router this works from a Server Component with no "use client" of your own — the boundary is drawn at @cheela/ui’s own client modules. The NEXT_PUBLIC_ prefix says out loud that the value reaches the browser, which for this key is fine.

Chat reads its client from context and never takes an apiKey directly, so several <Chat/> instances can share one provider without re-authenticating.

Reacting to messages

TypeScript
<Chat
  initialMessages={saved}
  onMessage={(message) => persist(message)}
  onError={(error) => toast(error.message)}
  renderMessage={(message) => <MyBubble message={message} />}
/>

onMessage fires once per finished assistant message, not once per streamed token.

Custom element

For pages that are not React. The element registers itself on import.

HTML
<script type="module" src="/path/to/cheela-chat.js"></script>

<cheela-chat
  api-key="ch_pk_..."
  theme="auto"
  placeholder="Ask me anything..."
></cheela-chat>
ATTRIBUTEVALUES
api-keyThe runtime public key. Required.
base-urlOverride the control plane host. Rarely needed.
themelight, dark, or auto
placeholderInput placeholder text

Attributes are live — changing one reconfigures the mounted widget rather than remounting it. There is also a programmatic equivalent:

JavaScript
document.querySelector("cheela-chat").configure({
  theme: "dark",
  placeholder: "How can we help?",
});

One script tag

No bundler, no module system, no build step. The loader script defines the element and fetches its core chunk only when one mounts.

index.html
<div id="chat-root"></div>

<script src="https://unpkg.com/@cheela/web-component/dist/cheela-chat.js"></script>
<script>
  window.Cheela.init(document.getElementById("chat-root"), {
    apiKey: "ch_pk_...",
    placeholder: "Ask me anything...",
  });
</script>

A live one is running at demo-shop. Note that its panel does not use the snippet above: <cheela-chat> renders into a shadow root with its own styling, so the storefront imports @cheela/web-component/headless instead and draws its own surface against that state. Reach for the script tag when you want the chat to look like Cheela's, and the headless entry point when it has to look like yours.

Buttons, not links in prose

A capability that creates a checkout returns a URL. Left to the model, that URL reaches the shopper only if it chooses to repeat it — and models mangle long signed URLs. Return an action instead and the widget renders a button, every time, without the model involved in the presentation.

capability handler
return {
  orderId: order.id,
  total: order.amount,
  cheela: {
    actions: [
      {
        type: "link",
        label: `Pay ₹${order.amount / 100}`,
        url: order.checkoutUrl,
        style: "primary",
      },
    ],
  },
};

Everything outside cheela is yours and reaches the model unchanged, so the assistant can still say what it did. The model decides whether to call the capability; the UI decides how the result looks.

This is how payment works on Cheela. Your runtime creates the checkout with your own payment provider and your own key, and returns the link. No card details ever pass through Cheela, the model, or the conversation.

Only https:// links render

The output is written by your runtime and rendered inside your visitor’s browser, so a javascript: URL there would be stored XSS on your own domain. Anything that is not https: is dropped, along with malformed entries, and at most five actions render per result.

Letting the visitor answer

A link action sends someone out of the conversation. A reply action keeps them in it. Pressing one submits a turn on their behalf, exactly as if they had typed it — so a capability can offer the four sizes it actually has in stock instead of hoping the model recognises “the 42 one” in free text.

capability handler
return {
  orderId: order.id,
  cheela: {
    actions: [
      {
        type: "reply",
        label: "Yes, cancel it",
        value: `cancel order ${order.id}`,
        style: "primary",
      },
      { type: "reply", label: "No, keep it" },
      { type: "link", label: "View order", url: order.url },
    ],
  },
};

label is what the visitor reads; value is what the model receives. Splitting them is what lets a button read Yes, cancel it while the turn says cancel order ord_1042 — unambiguous on its own, with no dependency on what the button happened to sit underneath. Leave value out and the label is used, which is usually what you want for a plain No, keep it.

Both kinds live in the same actions array and render in the order you return them, so one result can offer a choice and a way out of it at the same time. Replies count against the same limit of five.

A reply is billed like anything else typed

value is submitted verbatim as the next turn, so it becomes input tokens you pay for. Anything longer than 512 characters is dropped rather than shortened — half of cancel order ord_1042 is a different instruction. A value that is present but is not a string is refused for the same reason, rather than quietly falling back to the label.

Showing products

The same argument, for things people look at before they buy them. A capability that searches your catalogue returns products, and left to the model the shopper gets a paragraph about them: no picture, and prices retyped from memory. Return cards and the widget renders them.

capability handler
return {
  queryId: results.id,
  cheela: {
    cards: results.items.map((item) => ({
      type: "product",
      title: item.name,
      price: `₹${item.price / 100}`,
      description: item.summary,
      image: { url: item.imageUrl, alt: item.imageAlt },
      url: item.pageUrl,
    })),
    actions: [
      { type: "link", label: "See all", url: results.allUrl },
    ],
  },
};

Only type and title are required — a card with no picture, price or link still renders. price is a string you have already formatted, because your runtime knows the currency and the locale and the widget knows neither.

Cards and actions travel together

One result can return both: a shortlist of cards and a button to see the rest. Image and link URLs get the same https:-only treatment as actions, and the damage from a bad one is kept as small as it can be — a rejected image costs the card its picture, not the card, and a rejected card costs you that card, not the reply. At most six cards render per result.

Waiting for the payment to land

The button sends the shopper to your checkout, and until they come back the conversation has nothing to say. Add a pending spec and the widget watches for you: it re-calls a capability of yours until that capability reports the work is finished, then hands the result to the model so it can carry on.

capability handler
return {
  orderId: order.id,
  cheela: {
    actions: [
      { type: "link", label: `Pay ₹${order.amount / 100}`, url: order.checkoutUrl },
    ],
    pending: {
      capability: "order-status",
      input: { orderId: order.id },
      intervalMs: 3000,
      timeoutMs: 900000,
    },
  },
};

The capability being watched answers in your own vocabulary and says outright whether it is done:

order-status handler
const order = await db.orders.findById(input.orderId);

return {
  status: order.status,
  amount: order.amount,
  cheela: { settled: order.status === "paid" },
};

That is the whole integration. There is no webhook to register with Cheela, no payment credential to hand over, no change to your checkout page or its redirect URLs, and no payment SDK in the shopper’s browser. You already have a row that flips when money arrives; this reads it.

settled is your word, never ours — we do not inspect status or try to guess which of your states means done. That is also why this is not a payment feature: the same spec waits on a KYC check, a human approval, or any slow job.

  • Polling happens in the visitor’s browser and wakes as soon as they return to the tab, so coming back from checkout resolves at once rather than waiting out the interval.
  • intervalMs has a floor of one second and timeoutMs a ceiling of fifteen minutes. Each poll is metered as one capability call with zero tokens.
  • A poll that fails is retried until the deadline; a visitor who types something abandons the wait.
  • On timeout the model is told the work never settled, so it can offer to check again instead of going silent.
The result reaches the model as a tool call

Not as a message from the visitor. The poll really did call your capability, so it enters the transcript as an ordinary tool call and result — the model reads it as something it observed, not as an unverified claim that someone paid.

Signed-in visitors

If any capability is marked requiresEndUser, the widget has to pass the visitor’s credential. Give it a function, not a string:

TypeScript
<CheelaProvider
  apiKey={publicKey}
  endUserToken={() => session?.accessToken}
>
  <Chat />
</CheelaProvider>

A shopper can sign in long after the widget mounted, and a value read once would pin whatever was true then. Returning undefined is correct for a signed-out visitor — capabilities requiring a user then refuse to run, which is the point.

Passing an inline arrow does not rebuild the client or drop the conversation; the provider holds it behind a stable wrapper. Full detail in End-user identity.

Styling

Components carry stable class names — cheela-chat, cheela-chat__error, and equivalents on the primitives — and ship no opinionated CSS beyond layout. Style them from your own stylesheet, or pass className.

CSS
.cheela-chat {
  height: 100%;
  border: 1px solid var(--line);
  border-radius: 12px;
}

theme accepts light, dark or auto; the resolved value lands on a data-cheela-theme attribute you can target.

The custom element is different: its Shadow DOM is what stops a host page’s CSS leaking in, which also stops yours reaching the widget. Two supported ways in, both from your own stylesheet — the --cheela-* custom properties, and ::part() for anything a variable cannot express.

CSS
cheela-chat {
  --cheela-color-accent: #6d28d9;
}

cheela-chat::part(message--user) { border-radius: 4px }
cheela-chat::part(action--primary) { background: #111; color: #fff }

Parts: container, messages, message, message--user, message--assistant, message--system, message--cards, empty, actions, action, action--primary, action--secondary, action--reply, action-label, action-description, cards, card, card--link, card-media, card-image, card-body, card-title, card-price, card-description, error, form, input, send.

Building your own UI

Two levels below <Chat/>. Use the hook to keep the state machine and write your own markup:

TypeScript
import { useCheelaChat } from "@cheela/ui";

function MyChat() {
  const { messages, status, error, sendMessage } = useCheelaChat();
  // your markup
}

Or drop React entirely and use @cheela/client, which is framework-agnostic: an ExecutionClient, a ConversationStore, an event emitter, and a markdown parser.

TypeScript
import { ExecutionClient, ConversationStore } from "@cheela/client";

const client = new ExecutionClient({ apiKey: "ch_pk_..." });
const store = new ConversationStore();

Outside React, @cheela/web-component/headless gives you the same conversation plus the DOM builders, and registers no custom elements — importing a controller should not silently define three tags on your page.

TypeScript
import {
  createChatController,
  renderMessage,
} from "@cheela/web-component/headless";

const chat = createChatController({ apiKey: "ch_pk_..." });

chat.subscribe((state) => {
  const bubbles = state.messages
    // Pass the handler through, or reply buttons are left out.
    .map((message) => renderMessage(message, {
      onReply: (value) => chat.sendMessage(value),
      disabled: state.status === "submitting",
    }))
    .filter(Boolean);

  list.replaceChildren(...bubbles);
});

chat.sendMessage("hello");

Reply buttons need somewhere to send their answer, so renderMessage and createMessageList take a handler. Leave it out and replies are dropped rather than rendered dead — links beside them still render, so a missing handler never costs you the checkout button.

Or keep our parts and arrange them yourself. <cheela-chat-messages> and <cheela-chat-input> find each other by session rather than by nesting, so they can sit anywhere in your layout and still be one conversation.

HTML
<div class="my-layout">
  <cheela-chat-messages session="support" api-key="ch_pk_..."></cheela-chat-messages>

  <!-- your own composer, your own markup -->
  <cheela-chat-input session="support" api-key="ch_pk_..."></cheela-chat-input>
</div>

Lock it to your domain

A public key in public HTML can be copied into someone else’s page. An origin allowlist stops browsers using it from anywhere but your sites:

Terminal
curl -X PUT https://api.cheelalabs.com/v1/runtimes/$RUNTIME_ID/allowed-origins \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "origins": ["https://www.example.com", "https://app.example.com"] }'

Bare origins only — no trailing slash, no path. An entry with a path can never equal a browser’s Origin header, so it is rejected rather than silently matching nothing.

An allowlist is not a secret

It constrains browsers, which send Origin honestly. It does not constrain curl. Rate limits and quota are what bound abuse from a non-browser caller.