Widget Channel
Use a widget channel when people should talk to your Agent inside your own website or product, not on a separate hosted page.
This page focuses on the embeddable Widget flow. If you want a hosted page from Codeer, use Web Channel. If you want to fully own the UI and backend flow, use API Access.
For Consultation Desk, the safest rollout order is:
- Publish the Agent version.
- Create or update the widget channel.
- Configure the widget basics and access rules.
- Publish the widget channel.
- Copy the embed snippet into your host page.
- Test the live page yourself before sharing it.
What to have before you start
- A published Agent version
- Permission to edit channels in the workspace
- A website or product page where your team can add the embed snippet
- If you want signed-in users to enter the widget as themselves, someone on your team who can update the host backend
Full setup flow
-
Publish the Agent first
- Open the target Agent in
Editorand clickPublish. - Publishing the widget channel does not publish the Agent for you.
- If the Agent has
suggested questions, the widget will reuse them as its panel suggestions after publish.
- Open the target Agent in
-
Create the widget channel in
Channels- Go to
Channels. - Click
New Channel. - Choose
Widget. - Fill in a stable
NameandSlug.
Treat the slug as part of the embed contract
For widget channels,
widgetKeyis the channel slug. If you change the slug later, you need to publish again and update any copied embed snippet and signed login flow that depends on the old slug. - Go to
-
Configure the widget basics
In the widget configuration, check these fields first:
Agent: choose the published Agent that should answer in the widgetLauncher LabelandLauncher Icon: define how the closed widget entry looks on your pageWidget Title: the title shown in the panel headerWidget Language: chooseEnglish,繁體中文, or日本語Widget Position: usuallybottom-rightorbottom-leftGreeting: the first text users see in the panel
Suggested Questionsare not managed separately in the widget config. They come from the published Agent version you bind to this channel. -
Set access and security
The widget has two separate decisions here:
- Anonymous access
- Turn this on when visitors should be able to start as guests.
- Turn this off only when your host page should always pass a signed-in identity.
- Origin restrictions
- Use
Configured origins onlywhen the widget should run only on specific sites. - Add one exact origin per line, for example
https://support.example.com. - Do not enter page paths here. Use only the origin.
- Use
For signed-in widget users, choose one login mode:
Disabled: the widget stays in guest modeAssertion: recommended for production; your host backend signs and proves the user identityDirect User ID: lower security; use only in trusted environments
Do not turn off guest access unless the host app really logs users in
If anonymous access is off and your host page does not pass a valid signed-in identity, users will not be able to start a conversation.
Direct login needs origin restrictions
Before you use direct login, switch the origin policy to
Configured origins onlyand add at least one allowed origin.The shared secret is shown only once
If you use
Assertion, generate the shared secret and store it in your host backend immediately. It is never exposed inconfig.json, and the full value will not be shown again after you leave the page. - Anonymous access
-
Publish the widget channel and copy the embed snippet
- After the config is ready, publish the widget channel.
- In
Overview, copy one of the generated embed snippets. Auto-initis the default choice when you want the widget to use the published config as-is.Manual initis only for cases where your host page needs runtime overrides before the script loads.
-
Test the live page yourself
Before sharing the page more broadly, open the real host page and check:
- the launcher appears in the expected corner
- the launcher label, icon, title, and greeting are correct
- the widget is using the correct Agent
- guest access or signed-in access behaves the way you intended
- origin restrictions are not blocking the real page
- conversations appear in
Histories
How to use widget.js in your host app
The simplest approach is to copy the generated embed snippet from the widget channel Overview and paste it into your host page.
Simplest embed: Auto-init
If you just want to use the published channel config as-is, this is usually enough:
<script async src="https://app.codeer.ai/web-widget/widget.js?key=YOUR_WIDGET_KEY"></script>
The one important detail is:
YOUR_WIDGET_KEYis the widget channelslug
This loads widget.js, auto-initializes the widget, and uses the published config.json.
Advanced embed: Manual init
If you need runtime overrides before the script loads, use manual init:
<script>
window.YourWidget =
window.YourWidget ||
function () {
(window.YourWidget.q = window.YourWidget.q || []).push(arguments)
}
window.YourWidget("init", {
widgetKey: "YOUR_WIDGET_KEY",
openOnInit: true,
appearance: {
title: "Support",
position: "bottom-left",
launcher: {
label: "Need help?",
},
},
})
</script>
<script async src="https://app.codeer.ai/web-widget/widget.js"></script>
What happens here:
- the first script queues the
initcommand - the second script loads the real
widget.js - once
widget.jsis ready, it executes the queuedinit - if multiple
initcalls are queued beforewidget.jsis ready, treat only the latest one as authoritative for widget state and callbacks
Start with Auto-init unless you need host-side control
If you do not need runtime overrides or host-driven control, Auto-init is usually the cleanest choice.
How to override settings at runtime
widget.js can override part of the published config during init(...). This is useful for preview environments, temporary experiments, or host-specific display changes.
Common override fields
configUrl- load a different
config.json
- load a different
apiRoot- point to a different API root
agentId- temporarily bind a different Agent
externalUserId- seed the guest / session manager with a stable external identity
appearance.titleappearance.positionappearance.launcher.labelappearance.launcher.iconUrlappearance.theme.baseColorappearance.offset.xappearance.offset.yappearance.zIndexthemeModeopenOnInit
Override example
<script>
window.YourWidget =
window.YourWidget ||
function () {
(window.YourWidget.q = window.YourWidget.q || []).push(arguments)
}
window.YourWidget("init", {
widgetKey: "support-widget",
configUrl: "https://cdn.example.com/widget-preview/config.json",
apiRoot: "https://api.preview.example.com",
openOnInit: true,
themeMode: "light",
appearance: {
title: "VIP Support",
position: "bottom-left",
launcher: {
label: "Chat with us",
iconUrl: "https://cdn.example.com/widget-icon.png",
},
theme: {
baseColor: "green",
},
offset: {
x: 24,
y: 24,
},
zIndex: 9999,
},
})
</script>
<script async src="https://app.codeer.ai/web-widget/widget.js"></script>
Override precedence
When the same field exists in multiple places, the order is:
- runtime overrides in
init(...) - published
config.json - widget defaults
Notes for overrides
appearance.offsetandappearance.zIndexare API-only; they are not published from the ConsolethemeModeis a separate runtime option, not part ofappearanceconfigUrlmay be absolute or relative; relative URLs resolve from the host page origin- for long-term production behavior, keep durable settings in the published channel and use runtime overrides only where necessary
widget.js API and examples
After the script loads, your host page can use:
window.YourWidget.init(...)window.YourWidget.open()window.YourWidget.close()window.YourWidget.destroy()window.YourWidget.loginUser(...)window.YourWidget.logoutUser()
init(...)
This initializes the widget. The minimum required field is:
widgetKey
You can also pass:
configUrlapiRootagentIdexternalUserIdappearancethemeModeopenOnInitonConfigLoadedonErroronStateChange
Example:
<script>
async function startWidget() {
await window.YourWidget.init({
widgetKey: "support-widget",
openOnInit: true,
onConfigLoaded: (config) => {
console.log("Widget config loaded", config)
},
onError: (error) => {
console.error("Widget error", error)
},
onStateChange: (snapshot) => {
console.log("Widget state", snapshot)
},
})
}
</script>
Important behavior:
- the widget runtime is a singleton, so repeated
init(...)calls reconfigure the same widget instance instead of creating separate widgets - if multiple
init(...)calls overlap, only the latest one controls widget state and callbacks such asonConfigLoadedandonError - older pending
init(...)promises may still resolve, but they do not re-apply stale state or fire stale callbacks
open() / close()
If you want your own buttons to control the widget:
<button onclick="window.YourWidget.open()">Open support</button>
<button onclick="window.YourWidget.close()">Close support</button>
destroy()
If your host app is a single-page app and you do not want to keep the current widget instance across route changes:
<script>
window.YourWidget.destroy()
</script>
Common reasons to use it:
- your host app route changes and you want a clean re-init
- the widget should not stay mounted on some pages
- you want to fully replace the current widget setup
User login and important notes
Widget login is not a built-in login screen. Your host app actively passes the signed-in identity into the widget.
Three modes
Disabled- the widget does not accept signed-in identity from the host app
- users can only start as guests
Assertion- recommended for production
- your host backend signs a short-lived assertion that proves the user identity
Direct User ID- the host app sends
externalUserIddirectly - lower security; use only in trusted environments
- the host app sends
Assertion mode example
In Assertion mode, your host app should call:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
getAssertion: async () => {
const response = await fetch("/api/widget/assertion", {
method: "POST",
credentials: "include",
})
const data = await response.json()
return data.assertion
},
})
}
</script>
The key points are:
- the assertion must come from your host backend, not from browser-side signing
- the widget channel shared secret must be stored on your own backend
getAssertion()in the browser should only call your own backend API and return the assertion it receives- the shared secret must never be exposed to browser code
- each assertion should be freshly generated when needed
What Assertion mode actually requires
If you use Assertion, the real implementation flow is:
- Generate the shared secret in the Codeer widget channel
- Store that shared secret in your own backend
- Build a backend API in your own system that creates assertions
- Let
getAssertion()call that backend API - Have the backend sign the assertion with the shared secret
- Return the signed assertion to the browser, then pass it to
window.YourWidget.loginUser(...)
In other words:
- Codeer verifies the assertion
- your own backend stores the shared secret and signs the assertion
- the browser should never know the shared secret
Assertion rules
The assertion must be a short-lived JWT signed with HS256. The payload must include:
sub- the identity that should become the widget user
channel_slug- must exactly match the widget channel slug
iat- issued-at time in Unix seconds
exp- expiry time in Unix seconds
jti- a new unique value for every assertion to prevent replay
If your channel is configured with an issuer, also include:
iss
Important rules:
expmust be later thaniatexpmust be no more than 5 minutes afteriat- generate a fresh
jtievery time - if you change the widget channel slug, update
channel_slugtoo
Assertion payload example
{
"sub": "member@example.com",
"channel_slug": "support-widget",
"iat": 1760000000,
"exp": 1760000240,
"jti": "8f4d0e6d-2a63-4d17-a5a3-2be6f4a34862",
"iss": "https://customer.example.com"
}
Node.js example: build the assertion in your backend
This example shows how your own Node.js backend can generate the assertion:
import crypto from "node:crypto"
import jwt from "jsonwebtoken"
export function buildWidgetAssertion({
externalUserId,
channelSlug,
sharedSecret,
issuer,
}) {
const now = Math.floor(Date.now() / 1000)
const payload = {
sub: externalUserId,
channel_slug: channelSlug,
iat: now,
exp: now + 240,
jti: crypto.randomUUID(),
}
if (issuer) {
payload.iss = issuer
}
return jwt.sign(payload, sharedSecret, {
algorithm: "HS256",
})
}
If you want to expose it as a backend API, the response can look like this:
app.post("/api/widget/assertion", async (req, res) => {
const signedInUser = req.user
const assertion = buildWidgetAssertion({
externalUserId: signedInUser.email,
channelSlug: "support-widget",
sharedSecret: process.env.WIDGET_SHARED_SECRET,
issuer: "https://customer.example.com",
})
res.json({ assertion })
})
Then the browser-side getAssertion() calls that API:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
getAssertion: async () => {
const response = await fetch("/api/widget/assertion", {
method: "POST",
credentials: "include",
})
const data = await response.json()
return data.assertion
},
})
}
</script>
Assertion mode cautions
- keep the shared secret only in your own backend
- never put the shared secret in frontend bundles, HTML, or public config
- never reuse an old assertion
channel_slugmust match the current widget channel slug- if you configured an
issuer, sign the assertion with the matchingiss - for production, prefer
Assertioninstead of defaulting toDirect User ID
Direct User ID mode example
Use this only when the channel is explicitly configured for Direct User ID:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
externalUserId: "member@example.com",
})
}
</script>
Logout example
<script>
async function logoutWidgetUser() {
await window.YourWidget.logoutUser()
}
</script>
Login-related cautions
Assertionis the recommended production modeDirect User IDis lower security because any JavaScript running on the host page may try anotherexternalUserId- before using
Direct User ID, switch toConfigured origins onlyand add at least one allowed origin widget.jsdoes not generate assertions for you; your backend must do that work- if the channel is configured for
Assertion, callloginUser({ getAssertion }) - if the channel is configured for
Direct User ID, callloginUser({ externalUserId }) - after
logoutUser(), the safest way to continue as a guest is to close and reopen the widget - if a guest session expires while the widget is open, the user will usually need to close and reopen it to continue
Final checks before rollout
- the host page uses the latest snippet copied from
Overview widgetKeymatches the current channel slug- if you changed the slug, the embed snippet and login flow were updated too
- production uses
Assertionunless you have a strong reason not to allowed originsincludes every real site that will load the widget- internal users or a small stakeholder group have tested it end to end before wider rollout
Controlled rollout
If this is the first live rollout, start with a narrow surface.
- Add only one staging or internal site to
allowed originsfirst - Share the page with a small stakeholder group before going wider
- Use Audience Access as well if you need workspace-level access control
- Avoid
Direct User IDmode for broad public rollout unless you have a clear reason