How to Build a Claude-Powered Chatbot Widget
How to Build a Claude-Powered Chatbot Widget
A chatbot widget is a small embeddable UI — usually a floating button that expands into a chat panel — that you drop into any website with a <script> tag. Building one powered by Claude means wiring up three pieces: a frontend widget that renders messages and handles streaming, a backend endpoint that talks to Claude so your API key never touches the browser, and a lightweight embed script that lets you (or your customers) install it anywhere with one line of HTML.
This guide walks through that architecture end to end: the widget UI, the server-side proxy, streaming responses token-by-token, and how to package it as a portable embed script. We'll use SubToAPI for the Claude connection since it gives you an application API key and HTTPS endpoint without managing OAuth or token refresh yourself — but the architecture below works with any Claude-compatible backend.
Why you need a backend, not just a frontend
The most common mistake is calling Claude directly from JavaScript in the browser. That means shipping your API key to every visitor, which anyone can extract from the network tab and use on your account. Instead:
- The widget sends the user's message to your own backend endpoint (e.g.
/api/chat). - Your backend calls Claude with a server-side secret.
- Your backend streams the response back to the widget.
This also gives you a place to add rate limiting, logging, system prompts, and auth per customer — none of which should live in client-side code.
Step 1: The backend proxy
A minimal Node/Express endpoint that forwards to Claude via SubToAPI:
app.post('/api/chat', async (req, res) => {
const { messages } = req.body;
const response = await fetch('https://api.subtoapi.app/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SUBTOAPI_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'claude-sonnet-4',
max_tokens: 1024,
system: 'You are a concise, friendly support assistant for Acme.',
messages
})
});
const data = await response.json();
res.json(data);
});
This is the non-streaming version — fine for a first pass. See /docs/messages for the full request/response shape.
Step 2: Add streaming for a responsive feel
Chatbot widgets feel noticeably better when text appears progressively instead of all at once after a 3-second wait. Stream from Claude and pipe tokens straight to the client over Server-Sent Events:
app.post('/api/chat/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
const upstream = await fetch('https://api.subtoapi.app/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SUBTOAPI_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'claude-sonnet-4',
max_tokens: 1024,
stream: true,
messages: req.body.messages
})
});
for await (const chunk of upstream.body) {
res.write(chunk);
}
res.end();
});
Details on event types and parsing deltas are in /docs/streaming.
Step 3: The widget frontend
The widget itself is just a chat UI that posts to your backend and renders the stream. Key pieces:
async function sendMessage(text) {
appendMessage('user', text);
const assistantEl = appendMessage('assistant', '');
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: conversation })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const delta = parseSSEDelta(buffer); // extract text_delta chunks
assistantEl.textContent += delta;
}
}
Keep the widget's DOM footprint small and scoped — use a shadow DOM or a unique prefix on all class names so it doesn't collide with the host site's CSS.
Step 4: Package it as an embed script
To let anyone install the widget with one line, bundle the UI, CSS, and fetch logic into a single JS file that self-mounts:
<script src="https://yourdomain.com/widget.js" data-site-id="acme-123" defer></script>
Inside widget.js, read data-site-id from the script tag, inject a container div, and mount the chat UI into it. The site-id lets your backend apply per-customer system prompts, rate limits, or branding without separate deploys.
Step 5: Add conversation memory and guardrails
Two things turn a demo into a usable product:
- Conversation history: store the last N messages client-side (or server-side keyed by a session ID) and send them with every request so Claude has context.
- Scope the system prompt: tell Claude explicitly what it should and shouldn't answer, and give it a fallback for out-of-scope questions ("I can only help with questions about Acme's product").
If you need the widget to take actions — look up an order, check a status — rather than just answer questions, add tool calls on the backend. See /docs/tools for the request format.
Keeping it production-ready
- Put the backend endpoint behind your own auth/rate limiting so one visitor can't exhaust your quota.
- Log requests with
metadata.user_idso you can trace usage per customer — useful if you're billing by seat or widget install. - Use application-scoped keys (
sub_live_...) rather than reusing one key across every environment, so a compromised staging key doesn't affect production. - Start on the free trial, then move to a plan once you know your message volume — Solo at €9 covers most single-site widgets, Team at €19/seat if multiple people manage the integration.
Full request examples and parameter reference live in /docs/quickstart and /pricing if you want to estimate cost before launch.
Questions
Do I need a backend, or can the widget call Claude directly from the browser? You need a backend. Calling Claude directly from client-side JavaScript exposes your API key to anyone who opens the browser's network tab, which is a guaranteed way to leak and abuse your credentials.
How do I make the widget feel fast? Stream the response token-by-token instead of waiting for the full completion, and show a typing indicator while the first token is in flight. Non-streaming requests on longer answers can feel sluggish even if total latency is similar.
Can the widget remember previous messages in a session? Yes — store the conversation array (user and assistant turns) either in the browser's session storage or server-side keyed by a session ID, and send the full history with each new request so Claude has context.