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.
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
npm install @cheela/uiimport { 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
<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.
<script type="module" src="/path/to/cheela-chat.js"></script>
<cheela-chat
api-key="ch_pk_..."
theme="auto"
placeholder="Ask me anything..."
></cheela-chat>| ATTRIBUTE | VALUES |
|---|---|
| api-key | The runtime public key. Required. |
| base-url | Override the control plane host. Rarely needed. |
| theme | light, dark, or auto |
| placeholder | Input placeholder text |
Attributes are live — changing one reconfigures the mounted widget rather than remounting it. There is also a programmatic equivalent:
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.
<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.
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.
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.
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.
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.
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.
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.
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:
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.
intervalMshas a floor of one second andtimeoutMsa 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.
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:
<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.
.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.
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:
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.
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.
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.
<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:
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.
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.