josie-widget
v3.0.1
Published
Official React component for embedding J.O.S.I.E chatbots in React and Next.js apps.
Maintainers
Readme
josie-widget
Official client library for embedding a J.O.S.I.E (Joe Solutions Intelligence Engine) chatbot into any website, React app, Next.js project, or mobile application.
- Web (any site) — one
<script>tag, floating chat bubble, zero dependencies - React / Next.js —
npm install josie-widget, drop in a component - Mobile (React Native, Swift, Kotlin) — call the REST API directly, no SDK required
Table of Contents
- Prerequisites
- Web — Script Tag (CDN)
- React / Next.js — npm package
- Mobile — REST API
- API reference
- Security
- Troubleshooting
1. Prerequisites
Before you start you need:
- A J.O.S.I.E account — sign up free at josie.ai/signup
- A configured chatbot — complete the setup wizard in your dashboard (takes about 2 minutes)
- An API key — generate one at josie.ai/dashboard/api-keys
Your widget API key looks like wk_live_a1b2c3d4e5f6.... It is a browser-safe key — keep it out of your server-side code. If a key is ever compromised, revoke it from the dashboard and create a new one.
2. Web — Script Tag (CDN)
The CDN widget is a single self-contained JavaScript file. It has zero dependencies and will not interfere with your existing CSS or JavaScript.
2.1 Floating bubble (default)
Paste this snippet anywhere in your HTML, ideally just before the closing </body> tag:
<script
src="https://josie.ai/widget.js"
data-josie-key="wk_live_YOUR_API_KEY"
data-app-name="Acme Support"
data-placeholder="How can we help you today?"
async
></script>A chat bubble will appear in the bottom-right corner of every page. Visitors click it to open and close the chat panel.
2.2 Inline embed
If you want the chatbot embedded inside a specific element on your page (e.g. a support page sidebar) instead of a floating bubble, add a data-target attribute pointing to a CSS selector:
<!-- 1. Create a container wherever you want the chat to appear -->
<div id="josie-chat" style="height: 560px;"></div>
<!-- 2. Load the widget and point it at the container -->
<script
src="https://josie.ai/widget.js"
data-josie-key="wk_live_YOUR_API_KEY"
data-app-name="Acme Support"
data-target="#josie-chat"
async
></script>The widget fills the container completely. Set the height on your container to whatever fits your layout.
2.3 All data attributes
| Attribute | Required | Default | Description |
|----------------------|----------|--------------------------------|-------------|
| data-josie-key | Yes | — | Your widget API key (wk_live_...) |
| data-app-name | No | "J.O.S.I.E" | Name shown in the chat header |
| data-placeholder | No | "Ask me anything..." | Input placeholder text |
| data-target | No | null (floating bubble) | CSS selector of an inline container |
| data-theme | No | auto (follows system theme) | "light" or "dark" to force a theme |
| data-position | No | "right" | "left" to move bubble to the bottom-left |
| data-api-url | No | Origin of widget.js src URL | Override the JOSIE API base URL |
2.4 WordPress
- Go to Appearance → Theme File Editor (or use a plugin like Insert Headers and Footers)
- Paste the snippet into your theme's
footer.phpjust before</body>, or into the "Footer Scripts" field of the plugin
<!-- Paste inside footer.php, before </body> -->
<script
src="https://josie.ai/widget.js"
data-josie-key="wk_live_YOUR_API_KEY"
data-app-name="My Business"
async
></script>Using a plugin is safer because it survives theme updates. Popular choices: Insert Headers and Footers, WPCode, or Code Snippets.
2.5 Shopify
- In your Shopify admin go to Online Store → Themes → Edit code
- Open
layout/theme.liquid - Paste the snippet just before
</body>
<!-- theme.liquid — just before </body> -->
<script
src="https://josie.ai/widget.js"
data-josie-key="wk_live_YOUR_API_KEY"
data-app-name="{{ shop.name }}"
data-placeholder="Ask us anything about your order..."
async
></script>The {{ shop.name }} liquid variable automatically uses your store name.
2.6 Webflow
- Go to your Project Settings → Custom Code
- Paste the snippet into the Footer Code section
- Publish your site
<script
src="https://josie.ai/widget.js"
data-josie-key="wk_live_YOUR_API_KEY"
data-app-name="My Brand"
async
></script>3. React / Next.js — npm package
3.1 Installation
npm install josie-widget
# or
yarn add josie-widget
# or
pnpm add josie-widgetRequirements: React 18 or later, react-dom 18 or later.
3.2 Basic usage
import { JosieChat } from "josie-widget";
export default function SupportPage() {
return (
<div style={{ height: "600px" }}>
<JosieChat
apiKey="wk_live_YOUR_API_KEY"
appName="Acme Support"
placeholder="How can we help you today?"
suggestions={[
"How do I reset my password?",
"Where is my order?",
"What are your pricing plans?",
]}
/>
</div>
);
}The component fills whatever container it is placed in. Always give the container an explicit height.
3.3 Next.js App Router
Because JosieChat uses React state and browser event listeners, it must live inside a Client Component. The recommended pattern is to wrap it:
// app/support/page.tsx ← Server Component, no "use client" needed here
import ChatWidget from "@/components/ChatWidget";
export default function SupportPage() {
return (
<main className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-2xl font-bold mb-6">Support</h1>
<div style={{ height: "580px" }}>
<ChatWidget />
</div>
</main>
);
}// components/ChatWidget.tsx ← Client Component
"use client";
import { JosieChat } from "josie-widget";
export default function ChatWidget() {
return (
<JosieChat
apiKey={process.env.NEXT_PUBLIC_JOSIE_API_KEY!}
appName="Acme Support"
placeholder="How can we help?"
/>
);
}Store your API key in .env.local:
# .env.local
NEXT_PUBLIC_JOSIE_API_KEY=wk_live_YOUR_API_KEYNote:
NEXT_PUBLIC_prefix is intentional — the key is used in the browser by the widget to call the chat API. It does not grant access to your dashboard or any admin functions. Before going live, add your production domain to the Allowed Domains list in your J.O.S.I.E dashboard so the key cannot be used from other origins.
3.4 Next.js Pages Router
// pages/support.tsx
import type { NextPage } from "next";
import { JosieChat } from "josie-widget";
const SupportPage: NextPage = () => {
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "3rem 1rem" }}>
<h1>Support</h1>
<div style={{ height: 580 }}>
<JosieChat
apiKey={process.env.NEXT_PUBLIC_JOSIE_API_KEY!}
appName="Acme Support"
/>
</div>
</main>
);
};
export default SupportPage;3.5 Vite / CRA / Remix
// src/pages/Support.tsx (Vite / CRA)
import { JosieChat } from "josie-widget";
export function SupportPage() {
return (
<div style={{ height: "600px", maxWidth: "720px", margin: "0 auto" }}>
<JosieChat
apiKey={import.meta.env.VITE_JOSIE_API_KEY}
appName="Acme Support"
/>
</div>
);
}// app/routes/support.tsx (Remix)
// Remix loads on the server and client — add the "use client" directive or
// use ClientOnly from remix-utils to avoid SSR issues with useState.
import { ClientOnly } from "remix-utils/client-only";
import { JosieChat } from "josie-widget";
export default function Support() {
return (
<ClientOnly fallback={<div style={{ height: 600 }} />}>
{() => (
<div style={{ height: 600 }}>
<JosieChat apiKey={process.env.JOSIE_API_KEY!} appName="Acme" />
</div>
)}
</ClientOnly>
);
}3.6 All props
| Prop | Type | Required | Default | Description |
|---------------|------------|----------|------------------------|-------------|
| apiKey | string | Yes | — | Your widget API key (wk_live_...) |
| apiUrl | string | No | "https://josie.ai" | Base URL of the JOSIE API |
| appName | string | No | "J.O.S.I.E" | Name displayed in the chat header |
| placeholder | string | No | "Ask me anything..." | Input textarea placeholder |
| suggestions | string[] | No | undefined | Quick-reply chips on the welcome screen |
| showPoweredByBranding | boolean | No | loaded from API | Override tenant branding. When omitted, fetches GET /api/widget-settings. Set false on the tenant row in RDS to hide branding for all embeds. |
3.7 Tailwind setup
JosieChat renders Tailwind CSS utility classes. If your project already uses Tailwind, you need to tell it to scan the package so the classes are not tree-shaken:
// tailwind.config.js (Tailwind v3)
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/josie-widget/dist/**/*.{js,mjs}",
],
// ...
};// tailwind.config.ts (Tailwind v4 — add a source glob in your CSS)
// In your global CSS file:
// @source "../../node_modules/josie-widget/dist";If you are not using Tailwind in your project, the component will still render but without styles. In that case, wrap it in a container and apply your own styles, or switch to the CDN widget which has all styles built in.
4. Mobile — REST API
There is no native mobile SDK — you simply call the chat REST endpoint directly from your app with an HTTP client. This works in React Native, Swift, Kotlin, Flutter, and any other environment that can make HTTP requests.
4.1 Request format
POST https://josie.ai/api/chat
Authorization: Bearer wk_live_YOUR_API_KEY
Content-Type: application/jsonRequest body:
{
"messages": [
{ "role": "user", "content": "Hello, what can you help me with?" }
],
"sessionId": "optional-uuid-from-a-previous-response"
}| Field | Type | Required | Description |
|------------|------------------|----------|-------------|
| messages | array | Yes | Full conversation history. Each item has role ("user" or "assistant") and content (string). Always include the complete history — the API is stateless between requests. |
| sessionId| string (UUID) | No | Session ID returned by the server on the first response. Include it on subsequent requests to associate messages with the same conversation in your analytics. |
4.2 Response format (SSE)
The response is a Server-Sent Events (SSE) stream with Content-Type: text/event-stream. Each line is a data: event:
data: {"sessionId":"550e8400-e29b-41d4-a716-446655440000"}
data: {"content":"Hello"}
data: {"content":"! I"}
data: {"content":" can help you with..."}
data: [DONE]| Event | Meaning |
|---------------------------|---------|
| {"sessionId":"uuid"} | First chunk. Store this UUID and send it back in future requests as sessionId. |
| {"content":"token"} | A streamed token from the AI. Concatenate these to build the full reply. |
| [DONE] | Stream is complete. The full reply is everything you concatenated. |
Non-streaming clients: If your HTTP client does not support streaming, you can still read the full response body after the connection closes, then split it on \n\n and parse each data: line as JSON.
4.3 React Native example
import { useState } from "react";
const JOSIE_API_KEY = "wk_live_YOUR_API_KEY";
const JOSIE_URL = "https://josie.ai/api/chat";
type Message = { role: "user" | "assistant"; content: string };
export function useJosieChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
async function sendMessage(userText: string) {
const newMessages: Message[] = [
...messages,
{ role: "user", content: userText },
];
setMessages(newMessages);
setIsLoading(true);
// Append a placeholder for the assistant reply
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
try {
const res = await fetch(JOSIE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${JOSIE_API_KEY}`,
},
body: JSON.stringify({ messages: newMessages, sessionId }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? "Request failed");
}
// React Native's fetch does not support streaming — read the full body
const text = await res.text();
let fullReply = "";
let newSessionId = sessionId;
for (const line of text.split("\n")) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
if (parsed.sessionId) newSessionId = parsed.sessionId;
if (parsed.content) fullReply += parsed.content;
} catch (_) {}
}
if (newSessionId) setSessionId(newSessionId);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { role: "assistant", content: fullReply };
return updated;
});
} catch (error) {
console.error("JOSIE chat error:", error);
// Remove the empty assistant placeholder on error
setMessages((prev) => prev.slice(0, -1));
} finally {
setIsLoading(false);
}
}
return { messages, sendMessage, isLoading };
}Usage in a screen:
import { View, TextInput, Button, FlatList, Text } from "react-native";
import { useJosieChat } from "./useJosieChat";
import { useState } from "react";
export function SupportScreen() {
const { messages, sendMessage, isLoading } = useJosieChat();
const [input, setInput] = useState("");
function handleSend() {
if (!input.trim()) return;
sendMessage(input.trim());
setInput("");
}
return (
<View style={{ flex: 1, padding: 16 }}>
<FlatList
data={messages}
keyExtractor={(_, i) => String(i)}
renderItem={({ item }) => (
<Text style={{ color: item.role === "user" ? "blue" : "black" }}>
{item.role === "user" ? "You: " : "JOSIE: "}{item.content}
</Text>
)}
/>
<TextInput
value={input}
onChangeText={setInput}
placeholder="Ask me anything..."
editable={!isLoading}
/>
<Button title={isLoading ? "Thinking..." : "Send"} onPress={handleSend} disabled={isLoading} />
</View>
);
}4.4 Swift (iOS) example
import Foundation
class JosieChatService {
private let apiKey = "wk_live_YOUR_API_KEY"
private let apiURL = URL(string: "https://josie.ai/api/chat")!
private var sessionId: String?
struct Message: Codable {
let role: String
let content: String
}
func sendMessage(
_ userText: String,
history: [Message],
completion: @escaping (Result<String, Error>) -> Void
) {
var messages = history
messages.append(Message(role: "user", content: userText))
var body: [String: Any] = [
"messages": messages.map { ["role": $0.role, "content": $0.content] }
]
if let sid = sessionId { body["sessionId"] = sid }
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
// URLSession does not natively stream SSE — read full response
URLSession.shared.dataTask(with: request) { [weak self] data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let text = String(data: data, encoding: .utf8) else {
completion(.failure(NSError(domain: "JOSIE", code: -1)))
return
}
var fullReply = ""
for line in text.components(separatedBy: "\n") {
guard line.hasPrefix("data: ") else { continue }
let payload = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
guard payload != "[DONE]",
let jsonData = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
else { continue }
if let sid = json["sessionId"] as? String { self?.sessionId = sid }
if let token = json["content"] as? String { fullReply += token }
}
completion(.success(fullReply))
}.resume()
}
}4.5 Kotlin (Android) example
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
class JosieChatService {
private val client = OkHttpClient()
private val apiKey = "wk_live_YOUR_API_KEY"
private val apiUrl = "https://josie.ai/api/chat"
private var sessionId: String? = null
data class Message(val role: String, val content: String)
suspend fun sendMessage(userText: String, history: List<Message>): String =
withContext(Dispatchers.IO) {
val messages = history + Message("user", userText)
val messagesArray = JSONArray().apply {
messages.forEach { put(JSONObject().put("role", it.role).put("content", it.content)) }
}
val body = JSONObject().apply {
put("messages", messagesArray)
sessionId?.let { put("sessionId", it) }
}
val request = Request.Builder()
.url(apiUrl)
.post(body.toString().toRequestBody("application/json".toMediaType()))
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.build()
client.newCall(request).execute().use { response ->
val text = response.body?.string() ?: return@withContext ""
var fullReply = ""
text.split("\n").forEach { line ->
if (!line.startsWith("data: ")) return@forEach
val payload = line.removePrefix("data: ").trim()
if (payload == "[DONE]") return@forEach
runCatching {
val json = JSONObject(payload)
json.optString("sessionId").takeIf { it.isNotBlank() }?.let { sessionId = it }
fullReply += json.optString("content", "")
}
}
fullReply
}
}
}OkHttp dependency — add to your
build.gradle.kts:implementation("com.squareup.okhttp3:okhttp:4.12.0")
4.6 Error responses
When the request fails (non-2xx status), the response body is a JSON object:
{ "error": "Human-readable error message" }Common status codes:
| Status | Meaning |
|--------|---------|
| 400 | Bad request — messages array is missing or empty |
| 401 | Unauthorized — API key is missing, invalid, or revoked |
| 403 | Forbidden — either the request domain is not in the tenant's allowed list, or the subscription is inactive |
| 422 | Out of scope — the message was blocked by the guardrails; the error value is the refusal message you should show the user |
| 429 | Monthly usage limit reached — the tenant needs to upgrade their plan |
| 500 | Internal server error — retry with exponential back-off |
4.7 Session management
A sessionId ties multiple messages together into a single conversation in the analytics dashboard. Best practices:
- Save the
sessionIdfrom the first SSE chunk of each new conversation - Pass it back in every subsequent request in the same conversation
- When the user starts a new conversation, omit
sessionId(or set it tonull) — the server will create a new session - On mobile, store
sessionIdin memory (not on disk) unless you explicitly want conversation history to persist across app restarts
5. API reference
POST /api/chat
| Header | Value |
|-----------------|-------|
| Authorization | Bearer wk_live_YOUR_API_KEY |
| Content-Type | application/json |
Request body schema:
{
messages: Array<{ role: "user" | "assistant"; content: string }>;
sessionId?: string;
}Response: text/event-stream
Each SSE event is a data: line containing JSON:
// First event — always present
{ sessionId: string }
// Subsequent events — streamed tokens
{ content: string }
// Final event — signals end of stream
"[DONE]"6. Security
API key exposure in the browser
Widget API keys (wk_live_*) are intentionally used in the browser — the same model used by Stripe.js, Mapbox, and every other browser-side SDK. The key can only trigger a chat conversation for your configured bot; it cannot access your dashboard, modify your configuration, or perform any admin operations.
Domain allowlisting (required before going live)
To prevent your key from being copied and used on other websites, J.O.S.I.E enforces origin-based allowlisting. Until you configure allowed domains the API accepts requests from any origin, which is fine for local development.
Before deploying to production you must add your domain(s):
- Go to your J.O.S.I.E dashboard → Configure
- Scroll to Allowed Domains
- Add each hostname that will embed the widget (e.g.
mycompany.com,app.mycompany.com) - Save — the restriction takes effect immediately
Once configured, any request from an unlisted domain will receive a 403 response. Your own domains will receive the correct Access-Control-Allow-Origin header automatically.
Other security notes
- Rotate compromised keys. If a key leaks, revoke it from Dashboard → API Keys and generate a new one. Existing sessions will fail immediately.
- Mobile / server-to-server calls do not send an
Originheader, so they bypass domain allowlisting by design — this is safe because the key cannot be scraped from a native binary the same way it can from client-side JavaScript. - Rate limiting: Demo keys (
wk_demo_*) are rate-limited per IP. Live keys are limited by your subscription's monthly message quota.
7. Troubleshooting
The widget does not appear
- Confirm the
<script>tag is in the<body>, not<head> - Confirm
data-josie-keyis set and is a valid live key - Open the browser console and look for
[JOSIE Widget]warnings
I get a CORS error in the console
This almost always means the domain making the request is not in your Allowed Domains list. Add the hostname (e.g. mycompany.com) to your dashboard → Configure → Allowed Domains and try again. CORS errors can also appear if you mistype the data-api-url attribute.
I get a 403 "This domain is not authorised" error
Same root cause as the CORS issue above. The Origin header of the request doesn't match any domain in your allowlist. Steps to fix:
- Open Dashboard → Configure → Allowed Domains
- Add the exact hostname (no
https://, no trailing slash) — e.g.mycompany.com - If testing locally, add
localhostto the list temporarily (or setIS_DEV=truein your.env.local)
The chat sends but never gets a response (React Native / mobile)
React Native's fetch does not support streaming. See section 4.3 — you need to read the full response body and parse the data: lines manually.
TypeScript errors after installing
Make sure your tsconfig.json includes "moduleResolution": "Bundler" or "Node16". The package ships with .d.ts files.
The component renders without styles (Tailwind purging)
Add the package path to your Tailwind content array. See section 3.7.
License
MIT © Joe Solutions
