Skip to content

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:

  1. Publish the Agent version.
  2. Create or update the widget channel.
  3. Configure the widget basics and access rules.
  4. Publish the widget channel.
  5. Copy the embed snippet into your host page.
  6. 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

  1. Publish the Agent first

    • Open the target Agent in Editor and click Publish.
    • 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.
  2. Create the widget channel in Channels

    • Go to Channels.
    • Click New Channel.
    • Choose Widget.
    • Fill in a stable Name and Slug.

    Treat the slug as part of the embed contract

    For widget channels, widgetKey is 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.

  3. Configure the widget basics

    In the widget configuration, check these fields first:

    • Agent: choose the published Agent that should answer in the widget
    • Launcher Label and Launcher Icon: define how the closed widget entry looks on your page
    • Widget Title: the title shown in the panel header
    • Widget Language: choose English, 繁體中文, or 日本語
    • Widget Position: usually bottom-right or bottom-left
    • Greeting: the first text users see in the panel

    Suggested Questions are not managed separately in the widget config. They come from the published Agent version you bind to this channel.

  4. 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 only when 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.

    For signed-in widget users, choose one login mode:

    • Disabled: the widget stays in guest mode
    • Assertion: recommended for production; your host backend signs and proves the user identity
    • Direct 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 only and 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 in config.json, and the full value will not be shown again after you leave the page.

  5. 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-init is the default choice when you want the widget to use the published config as-is.
    • Manual init is only for cases where your host page needs runtime overrides before the script loads.
  6. 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_KEY is the widget channel slug

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 init command
  • the second script loads the real widget.js
  • once widget.js is ready, it executes the queued init
  • if multiple init calls are queued before widget.js is 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
  • 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.title
  • appearance.position
  • appearance.launcher.label
  • appearance.launcher.iconUrl
  • appearance.theme.baseColor
  • appearance.offset.x
  • appearance.offset.y
  • appearance.zIndex
  • themeMode
  • openOnInit

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:

  1. runtime overrides in init(...)
  2. published config.json
  3. widget defaults

Notes for overrides

  • appearance.offset and appearance.zIndex are API-only; they are not published from the Console
  • themeMode is a separate runtime option, not part of appearance
  • configUrl may 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:

  • configUrl
  • apiRoot
  • agentId
  • externalUserId
  • appearance
  • themeMode
  • openOnInit
  • onConfigLoaded
  • onError
  • onStateChange

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 as onConfigLoaded and onError
  • 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 externalUserId directly
    • lower security; use only in trusted environments

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:

  1. Generate the shared secret in the Codeer widget channel
  2. Store that shared secret in your own backend
  3. Build a backend API in your own system that creates assertions
  4. Let getAssertion() call that backend API
  5. Have the backend sign the assertion with the shared secret
  6. 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:

  • exp must be later than iat
  • exp must be no more than 5 minutes after iat
  • generate a fresh jti every time
  • if you change the widget channel slug, update channel_slug too

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_slug must match the current widget channel slug
  • if you configured an issuer, sign the assertion with the matching iss
  • for production, prefer Assertion instead of defaulting to Direct 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>
  • Assertion is the recommended production mode
  • Direct User ID is lower security because any JavaScript running on the host page may try another externalUserId
  • before using Direct User ID, switch to Configured origins only and add at least one allowed origin
  • widget.js does not generate assertions for you; your backend must do that work
  • if the channel is configured for Assertion, call loginUser({ getAssertion })
  • if the channel is configured for Direct User ID, call loginUser({ 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
  • widgetKey matches the current channel slug
  • if you changed the slug, the embed snippet and login flow were updated too
  • production uses Assertion unless you have a strong reason not to
  • allowed origins includes 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 origins first
  • 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 ID mode for broad public rollout unless you have a clear reason