Embed widget
Drop a chat bubble into your site with one <script> tag. The widget
runs in an iframe served from https://api.juglans.ai/embed/iframe,
mints its own per-visitor session against the project's embed config,
and streams chat through /api/embed/chat — your jg_p_* project
key never touches the browser.
30-second start
- Open the project's API tab in the dashboard, then switch to the Embed sub-tab (
/app/project/api?tab=embed). - Pick a default agent → Enable embed.
- Copy the auto-bubble snippet and paste into your site's
<head>:
<script
src="https://api.juglans.ai/embed.js"
data-embed-id="emb_a1b2c3d4e5f6a7">
</script>
A floating bubble appears bottom-right of every page that includes the snippet. Click it to open the chat. Visitors get an anonymous session keyed by their browser's localStorage — same visitor = same conversation across page loads.
Three authentication modes
| Mode | Config | Best for |
|---|---|---|
anonymous |
(default) | Marketing sites, public docs, support widgets where you don't know who the visitor is |
identity_required |
embed config + HMAC secret | SaaS apps where your backend knows the user — pass their identity through so conversations are bound to your user id |
either |
as above | Both — public visitors get anonymous, signed-in users get identified |
Change the mode in API → Embed → Authentication mode
(/app/project/api?tab=embed) in the dashboard.
Anonymous mode
The default. No backend work on your side — just the <script> tag
above. The widget generates anon_<random> on first visit, stores
it in localStorage, and posts that as the X-USER-ID-equivalent.
Conversations are partitioned per browser.
Anonymous visitors are still rows in
project_external_users. From the dashboard you can see them asanon_*end-users and delete them (GDPR / abuse), though deletion does not block re-creation — see External users.
Identity verification (HMAC)
When you want the chat bound to your user id (so the agent knows who's asking, and conversations follow the user across devices), mint an HMAC secret and have your backend sign a short-lived token.
One-time: generate the HMAC secret
- In API → Embed, set Authentication mode to
identity_requiredoreither. - Click Generate secret under Identity Verification (HMAC).
- The plaintext is shown once — copy it now (
wse_…). The server only stores its encrypted form; the prefixwse_xxxxxxxxis what you'll see afterwards.
Each request: your backend signs a token
Sign hmac_sha256(secret, "{embed_id}.{user_id}.{ts}") and ship
{ts}.{base64url(sig)} to the browser. The browser passes it as
data-user-token on the script tag (or as the user-token
attribute on <juglans-chat>).
Server-side validation requires |now - ts| ≤ 300s, so use a freshly
minted token per page load.
Node.js
import crypto from 'node:crypto';
function juglansToken(embedId, userId, secret) {
const ts = Math.floor(Date.now() / 1000);
const sig = crypto
.createHmac('sha256', secret)
.update(`${embedId}.${userId}.${ts}`)
.digest('base64url');
return `${ts}.${sig}`;
}
// In your page template:
res.render('page', {
embedId: 'emb_a1b2c3d4e5f6a7',
userId: req.user.id,
userToken: juglansToken(
'emb_a1b2c3d4e5f6a7',
req.user.id,
process.env.JUGLANS_EMBED_SECRET,
),
});
Python
import base64, hashlib, hmac, time
def juglans_token(embed_id: str, user_id: str, secret: str) -> str:
ts = int(time.time())
msg = f"{embed_id}.{user_id}.{ts}".encode()
sig = hmac.new(secret.encode(), msg, hashlib.sha256).digest()
return f"{ts}.{base64.urlsafe_b64encode(sig).decode().rstrip('=')}"
# Pass into your template:
token = juglans_token("emb_a1b2c3d4e5f6a7", current_user.id, EMBED_SECRET)
PHP
function juglans_token(string $embed_id, string $user_id, string $secret): string {
$ts = time();
$msg = "$embed_id.$user_id.$ts";
$sig = hash_hmac('sha256', $msg, $secret, true);
$b64 = rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
return "$ts.$b64";
}
Ruby
require 'openssl'
require 'base64'
def juglans_token(embed_id, user_id, secret)
ts = Time.now.to_i
msg = "#{embed_id}.#{user_id}.#{ts}"
sig = OpenSSL::HMAC.digest('sha256', secret, msg)
b64 = Base64.urlsafe_encode64(sig, padding: false)
"#{ts}.#{b64}"
end
Pass the token to the widget
<script
src="https://api.juglans.ai/embed.js"
data-embed-id="emb_a1b2c3d4e5f6a7"
data-user-id="customer_47291"
data-user-token="<%= userToken %>">
</script>
The browser POSTs {embed_id, user_id, token} to
/api/embed/session; the server decrypts the stored HMAC secret,
re-computes the signature with the same payload, and constant-time
compares. A match returns an embed JWT bound to that user.
Allowed origins
The widget refuses to mint a session when the host page's Origin
isn't in the embed config's allowlist. Set this from Allowed
origins in the API → Embed panel — one per line, exact match:
https://app.acme.example
https://staging.acme.example
Special cases:
| Value | Effect |
|---|---|
| (empty) | Enforcement disabled — dev only. The server logs a warning per request. |
* (sole entry) |
Any origin. Marketing-site escape hatch. |
Subdomain wildcards (*.acme.example) are not supported in v1 —
list each one.
Prompt placement (hero search-bar style)
For landing pages and hero sections, where you want a permanent "ask me anything" input that lives in the page flow and pops the chat into a floating modal when the visitor submits:
<script type="module" src="https://api.juglans.ai/embed.js"></script>
<juglans-chat
embed-id="emb_a1b2c3d4e5f6a7"
position="prompt"
placeholder="Ask anything about pricing, integrations, or roadmap…">
</juglans-chat>
Behavior:
- The input renders directly in the host page flow (block element, max-width 720px, centered) — style it with your own CSS by wrapping it in a container.
- On submit, a bubble-style modal pops up at the bottom-right of the viewport. The first message is streamed there as the assistant reply.
- The input stays put. Submitting again continues the same
conversation (or starts a new one if the user closes & re-opens
the modal via
JuglansChat.close()+JuglansChat.startNew()). - Mobile-friendly: modal fills the viewport on screens < 480px.
Same identity / theme / HMAC flows as the other modes. The only
extra attribute is placeholder, which sets the input's empty-state
hint text.
Inline placement (Web Component)
For chats sitting in a page (sidebar widget, dedicated chat page, internal tooling), drop the auto-bubble and use the custom element:
<script type="module" src="https://api.juglans.ai/embed.js"></script>
<div style="height: 600px">
<juglans-chat
embed-id="emb_a1b2c3d4e5f6a7"
position="inline">
</juglans-chat>
</div>
The chat fills its parent container. Sizing is up to your CSS — the
chat reports back-edge content height only when explicitly opted in
(future autoresize attribute).
Attributes
| Attribute | Default | Effect |
|---|---|---|
embed-id |
(required) | The emb_* identifier. |
position |
bubble |
bubble = floating popup, inline = fill container. |
user-id |
— | Customer-side user id (pair with user-token). |
user-token |
— | HMAC token (above). |
show-header |
true |
Set to "false" to hide the title bar entirely. |
compact |
false |
Tightens paddings, hides the composer avatar. Use for narrow sidebars (≤ 380px). |
max-width |
720 |
Center-column cap in pixels. Useful when the iframe is wider than the chat should be. |
title |
(from server) | Override the agent name in the header. |
greeting |
(from server) | Override the empty-state greeting. |
suggestions |
(from server) | Suggestion chips above the composer. JSON array, or pipe / newline-separated. |
primary-color |
(from server) | CSS color for the accent. |
agent-bubble-color |
(from server) | CSS color for the assistant bubble background. |
bg-color |
(from server) | CSS color for the chat surface. |
Theming
theme is a free-form JSONB field on the embed config. The widget
reads the keys below; everything else is ignored verbatim (room to
grow).
| Key | Type | Effect |
|---|---|---|
theme.title |
string | Overrides the agent name in the header. |
theme.greeting |
string | Replaces "How can I help?" in the empty state. |
theme.suggestions |
string[] | Chip buttons shown above the composer when the log is empty. First 6 used. |
theme.colors.primary |
CSS color | Bubble + send button accent. |
theme.colors.primary_on |
CSS color | Text/icon color on the primary background. |
theme.colors.agent_bubble |
CSS color | Assistant message bubble background. |
theme.colors.bg_surface |
CSS color | Main chat background. |
theme.colors.bg_elevated |
CSS color | User bubble / composer pill background. |
theme.colors.text_primary |
CSS color | Main text color. |
theme.colors.text_secondary |
CSS color | Labels, placeholders. |
theme.colors.border |
CSS color | Composer + dividers. |
Set these from the Theme section inside API → Embed (color pickers + plain text inputs for greeting / suggestions).
Override from the page
Server-side theme is the default; you can override per-page from
your own JS or from extra attributes on the script / element. Two
shapes, same precedence (page wins):
Static attributes — handy when the values are known at render
time and you don't need runtime mutation. Works on both the auto
bubble (<script data-…>) and the explicit element
(<juglans-chat …>):
<juglans-chat
embed-id="emb_a1b2c3d4e5f6a7"
position="inline"
title="Acme Helpdesk"
greeting="Welcome back — what's broken today?"
suggestions='["Reset my password","Update billing","Talk to a human"]'
primary-color="#FF5722"
agent-bubble-color="rgba(255, 87, 34, 0.08)">
</juglans-chat>
suggestions accepts either a JSON array, pipe-separated
(a | b | c), or one-per-line. The first six entries are used.
Runtime API — call any time after embed.js has parsed; the
chat re-paints immediately. Useful when the values depend on a
state your SPA only has after hydration (i18n strings, the user's
plan, dark/light mode, etc.):
JuglansChat.configure({
title: 'Acme Helpdesk',
greeting: i18n.t('chat.greeting'),
suggestions: i18n.tArray('chat.suggestions'),
colors: {
primary: '#FF5722',
primary_on: '#FFFFFF',
agent_bubble: 'rgba(255, 87, 34, 0.08)',
},
});
// Clear an override and fall back to the server value:
JuglansChat.configure({ greeting: null });
// Target a specific element (when you have several on the page):
JuglansChat.configure({ title: 'Sales' }, '#chat-sales');
The loader sanitizes the patch before crossing the iframe origin boundary — only the whitelisted keys flow through:
| Top-level | Type |
|---|---|
title |
string |
greeting |
string |
suggestions |
string[] (first 6 used) |
colors |
object — keys below |
colors: primary, primary_on, agent_bubble, bg_surface,
bg_elevated, text_primary, text_secondary, border. Each is
any valid CSS color string.
Security-sensitive settings (mode, allowed_origins, the HMAC
secret, the branding_powered_by toggle) stay server-only and
cannot be patched from JS by design.
Endpoint reference
The widget calls these — listed here so you can debug from the browser network panel.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /embed.js |
None | The loader script. |
| GET | /embed/iframe?id=…&mode=… |
None | The chat iframe HTML. |
| GET | /api/embed/config?id=… |
None | Public subset of the embed config (theme, mode, origins, agent_name). |
| POST | /api/embed/session |
Origin-checked | Mint a short-lived (15 min) embed JWT from {embed_id, anon_id} or {embed_id, user_id, token}. |
| POST | /api/embed/chat |
Embed JWT | Stream a chat reply as SSE. Same `{type: "meta" |
| GET | /api/embed/conversations |
Embed JWT | List the current visitor's conversations. |
| GET | /api/embed/conversations/{cid} |
Embed JWT | Fetch one conversation with full message log — used by the iframe to hydrate history on reload. |
Troubleshooting
| Symptom | Likely cause |
|---|---|
Embed not found: "emb_…" in the bubble |
Wrong data-embed-id, embed disabled in the dashboard, or the <juglans-chat> element was rendered before the framework wired the embed-id attribute (Solid/Vue can do this — make sure you spell the attribute with a hyphen, not camelCase). |
Origin not allowed (403 on session) |
Add the customer's exact origin to Allowed origins, or use * for any. |
Identity verification required (403) |
Mode is identity_required and the script tag is missing data-user-token, or your token's ts is more than 5 minutes old. |
(no reply) in the chat bubble |
Server received the message but juglans library produced no events within 8s. Check agent_data/{id}/chat.jg exists and [ai.providers.juglans] in its juglans.toml is correct. |
| Markdown not rendering | Hard refresh — the loader script is cached for 5 minutes and the iframe HTML for 5 minutes. After a deploy, the very first iframe load may serve the cached old version. |