Add watsonx Orchestrate Embedded Chat
Scaffold a WXO embedded web chat into the agentic-cen-starter. The WXO widget is loaded
via a <script> tag at runtime — there are no npm packages to install. Everything goes
into frontend/src/components/watsonx/.
Step 0 — Run setup first (unfinalized projects only)
Before touching any source files, check whether the project has been finalized:
- Read
package.jsonand inspectcen.finalized. - If
cen.finalizedistrue— the project is already set up. Skip to Step 1. - If
cen.finalizedisfalse(or the field is absent) — the project is an unfinalized CEN Starter clone. This skill depends on the Carbon flavor (@carbon/react, Carbon tokens,useTheme, etc.). You must run setup before scaffolding the embed.
Do not ask the user to do setup manually. Drive it yourself:
- Activate the
setupskill. - Follow its interview flow with one pre-answered decision — do not ask the
user about it:
- Browser app or service? → always browser app. A WXO embedded chat is a UI by definition; never offer the backend-service path.
- All other setup questions (account management, which look) are asked normally — follow the setup skill's decision tree for those choices.
- Complete setup through finalization (step 6 of the setup skill) with the user's explicit confirmation.
- Only once
cen.finalizedistrueand the app is running, return here and continue with Step 1.
Step 1 — Ask for the layout
Use ask_followup_question with all four choices:
- float — Floating, minimizable, draggable chat bubble. Good when chat is supplementary to existing page content.
- custom — Two-column layout: a branding/guide panel alongside the chat. Most customizable.
- fullscreen — Chat fills the entire viewport. Immersive; supports chat history.
- docview — Side-by-side: chat on the left, a document viewer panel on the right. Links in chat open automatically in the viewer.
Then ask: Should the user be able to toggle between this layout and fullscreen?
(Layout switcher — only meaningful for float and custom. Skip for fullscreen and
docview.)
Step 2 — Scaffold the file tree
Create the following structure under frontend/src/components/watsonx/:
watsonx/
├── lib/
│ ├── types.ts # WXO TypeScript types + window augmentation
│ ├── utils.ts # env helpers, layout state, chat lifecycle
│ └── useAppTheme.ts # (placeholder — theme sync not scaffolded; see Step 7)
├── handlers/
│ └── orchestrateHandlers.ts # WXO event handlers (send/receive/feedback)
├── header/
│ ├── languageSelector.ts # custom header dropdown for locale switching
│ ├── layoutSwitcher.ts # custom header button: toggle default ↔ fullscreen
│ ├── dragHandle.ts # drag-to-move for float layout
│ └── documentViewer.ts # docview: link detection + viewer panel logic
└── core/
├── OrchestrateChat.tsx # initializes and owns a single WXO instance
├── OrchestrateLayout.tsx # layout router: picks Custom/Chat/DocView wrapper
├── CustomComponent.tsx # the custom two-column layout wrapper
└── DocumentViewPanel.tsx # the slide-in document viewer panel (docview only)
Step 3 — Implement each file
lib/types.ts
Define TypeScript types for the WXO configuration and instance. The key pieces:
LayoutType = "fullscreen" | "custom" | "docview" | "float"WxoConfiguration— mirrorswindow.wxOConfiguration(see Step 4 for the full shape)WxoInstance— the object passed tochatOptions.onLoad, with methods:on,off,destroy,updateLocale,updateCustomHeaderItems,getWriteableElement,loadThreadById,restartConversation,sendWxoChatPosition— positions forgetWriteableElement:"belowHeader" | "aboveInputBar" | "aboveWelcomeTitle" | "belowWelcomeStarters" | "chatWelcomeComponent"- WXO event types:
send,receive,pre:send,pre:receive,feedback,restartConversation,pre:threadLoaded,pre:stream:delta,chat:ready, etc. - Augment
WindowwithwxOConfiguration,wxoLoader({ init, chatInstance }).
lib/utils.ts
Environment / container helpers:
// Read VITE_WXO_AGENT_ID_2 with fallback to VITE_WXO_AGENT_ID
export const getEnvWithSuffix = (baseKey: string, suffix?: string): string | null => {
if (suffix) {
const v = import.meta.env[`${baseKey}_${suffix}`];
if (v) return v;
}
return import.meta.env[baseKey] || null;
};
export const getWxoContainerId = (suffix?: string) =>
suffix ? `wxo-container-${suffix}` : "wxo-container";
Layout state — module-level (not React state) so header items can read it without a re-render:
let currentLayout: LayoutType = "fullscreen";
let layoutChangeCallback: LayoutChangeCallback | null = null;
export const setLayoutState = (l: LayoutType, cb?: LayoutChangeCallback) => {
currentLayout = l; layoutChangeCallback = cb ?? null;
};
export const getCurrentLayout = () => currentLayout;
Layout persistence — store user preference and the default in localStorage:
getInitialLayout(suffix), saveLayoutPreference(layout, suffix),
saveDefaultLayout(suffix), getSavedDefaultLayout(suffix).
Chat lifecycle:
export const loadWxoScript = (hostURL: string): Promise<void> => {
// Set script.async = true and append to document.body (not document.head).
// Append <script src="${hostURL}/wxochat/wxoLoader.js?embed=true"> once.
// Resolve immediately if already loaded.
// IMPORTANT: window.wxOConfiguration must be assigned BEFORE this function
// is called. The WXO loader reads the config synchronously at parse time —
// setting it inside or after an onload callback is too late.
};
export const destroyChat = (containerId: string) => {
window.wxoLoader?.chatInstance?.destroy();
// Hide + reset the container div.
};
export const enableBodyScroll = (): void => {
// WXO (float mode in particular) sets overflow:hidden on both body AND
// document.documentElement (<html>). Both must be cleared or the rest of
// the app stays hidden behind the widget.
document.body.style.overflow = "auto";
document.body.style.position = "";
const htmlElement = document.documentElement;
if (htmlElement.style.overflow === "hidden") {
htmlElement.style.overflow = "auto";
}
};
Layout switcher guard:
// Only show the switcher when the default layout is custom or float.
export const shouldShowLayoutSwitcher = (suffix?: string): boolean => {
const def = getSavedDefaultLayout(suffix);
return layoutChangeCallback !== null && (def === "custom" || def === "float");
};
lib/useAppTheme.ts
Do not scaffold this file. Theme sync between the app and the WXO widget is not reliably achievable without a verified set of SCSS overrides for every WXO sub-component. WXO's own bundled stylesheet contains highly specific hardcoded rules that override Carbon zone class inheritance unpredictably. Attempting a partial implementation leaves surfaces with wrong colours and is harder to debug than no implementation at all.
Create an empty export as a placeholder:
// Theme sync not implemented.
// WXO renders with its own default theme.
// To add theme sync, obtain the full SCSS override set from your WXO embed reference
// project and apply it together with zone class stamping on <html>.
export function useWatsonxTheme(_containerId: string) {}
handlers/orchestrateHandlers.ts
Factory that returns all WXO event handlers. Accept { queryClient } for future
feedback API calls:
send— store the user's message text perthread_id(for feedback linking); callstoreCurrentThreadId.receive— linkparentMessageId→ user message; callstoreCurrentThreadId.pre:receive— enable the per-message feedback UI:lastItem.message_options = { feedback: { is_on: true, show_positive_details: false, show_negative_details: true, negative_options: { categories: ["Inaccurate", "Incomplete", "Too long", "Irrelevant", "Other"], disclaimer: "Provide content that can be shared publicly.", }, }, };feedback— log or persist the feedback. Leave a clear// TODO: call your APIcomment — do not invent a backend endpoint. Only wire up to the API if the project already has a feedback route.pre:send/pre:threadLoaded/restartConversation— delegate tocreateDocumentViewerHandlers()(only relevant when layout isdocview).
header/languageSelector.ts
Returns a WXO custom header dropdown for locale switching (en, fr, de, es, ja, it).
On change: call chatInstance.updateLocale(locale) and rebuild the header items array
to reflect the new locale text.
header/layoutSwitcher.ts
Returns a WXO custom header button that toggles between the saved default layout and
fullscreen. Use Carbon's Expand/Shrink icons encoded as SVG data URIs. On click:
saveLayoutPreference(newLayout, suffix); window.location.reload().
header/dragHandle.ts
Float layout only. Sets up mousedown/mousemove/mouseup listeners on the
.wxo-float-header element to make the .wxo-float container draggable within
viewport bounds. Use a MutationObserver to re-attach handlers if WXO recreates the
DOM (e.g. after minimize/maximize). Initialize with a ~2 s delay to let WXO render.
header/documentViewer.ts
Docview layout only. Two exports:
getCustomHeaderItem()— WXO header button (document icon) that shows/hides the#doc-viewerpanel.useDocumentViewerEffects(containerId)— React hook; attaches aMutationObserverto intercept link clicks inside the WXO container and open them in the viewer panel instead of a new tab.createDocumentViewerHandlers()— returnspre:receive,pre:send,pre:threadLoaded,restartConversationhandlers that extract URLs from message text, display them as tabs in the viewer, and reset the viewer on new conversation.
core/OrchestrateChat.tsx
The component that owns one WXO instance. Key points:
- Reads config from env via
getEnvWithSuffix("VITE_WXO_ORCHESTRATION_ID", suffix)etc. - Guards against double-init with a module-level
Set, not auseRef. React Strict Mode mounts → unmounts → remounts every component in development, resetting refs; the Set persists across remounts:const initializedContainers = new Set<string>(); // inside the useEffect: if (initializedContainers.has(containerId)) return; initializedContainers.add(containerId); - Assigns
window.wxOConfigurationfirst, then callsloadWxoScript(hostURL), then callswindow.wxoLoader.init(). Order matters — the loader reads the config synchronously at parse time. rootElementIDis always required, for every layout form includingfloat. Without it the widget has no container and silently does nothing:window.wxOConfiguration = { orchestrationID, hostURL, rootElementID: getWxoContainerId(suffix), // required for ALL layout forms // showLauncher: false is only meaningful for fullscreen-overlay; omit for float ...(layout !== "float" && { showLauncher: false }), // Auto-detect CP4D vs IBM Cloud from the host URL: deploymentPlatform: (hostURL ?? "").includes("cpd") ? "cp4d" : "ibmcloud", crn: null, chatOptions: { agentId, agentEnvironmentId, onLoad }, defaultLocale: "en", layout: { showHeader: true, form: layout === "custom" ? "custom" : layout === "float" ? "float" : "fullscreen-overlay", customElement: layout === "custom" ? containerRef.current : null, showOrchestrateHeader: true, width: "600px", height: "600px", }, style: { showBackgroundGradient: true, fontFamily: "IBM Plex Sans, sans-serif" }, header: { showResetButton: true, showAiDisclaimer: true, showAgentAvatar: false }, features: { showThreadList: layout !== "float", showAgentMemory: false }, // Anonymous access — empty string = no token required. // Replace with a real token for authenticated embedding. authTokenNeeded: (event) => { event.authToken = ""; }, } satisfies WxoConfiguration;- In
onLoad(instance): callenableBodyScroll()immediately (WXO setsoverflow:hiddenon bothbodyand<html>during init). Also callenableBodyScroll()again inside theonLoadcallback for any deferred paint. Then register event handlers, build the custom header items array (language selector- layout switcher if applicable + doc viewer button if docview), and call
instance.updateCustomHeaderItems(headerItems).
- layout switcher if applicable + doc viewer button if docview), and call
- Sync theme with
useWatsonxTheme(containerId). - On unmount (non-float): call
destroyChat(containerId). - Render the container div in the React tree (return it from JSX). Do not append
a detached div to
document.body— the WXO float widget still takes over the viewport regardless;enableBodyScroll()is the correct fix.return <div id={containerId} ref={containerRef} className="h-full w-full" />;
core/OrchestrateLayout.tsx
Reads the initial layout from getInitialLayout(suffix), holds it in React state.
Routes to the correct wrapper:
custom→<CustomComponent>docview→<OrchestrateChat>+<DocumentViewPanel>float/fullscreen→<OrchestrateChat>
Saves the default layout to localStorage on mount (saveDefaultLayout(suffix)).
core/CustomComponent.tsx
Two-column layout using Carbon tokens and Tailwind. Left panel: branding, "How to Use"
guide, links. Right panel: <OrchestrateChat>. Include a button to expand the chat
to full width (hides the left panel). Use only Carbon design tokens for colors — no
hardcoded hex values. Mark the descriptive copy as placeholder text so the user knows
to replace it.
core/DocumentViewPanel.tsx
A fixed right-side panel (#doc-viewer, initially display:none). Contains:
- A resizer handle (drag to resize width).
- Close and "open in new tab" buttons.
- A content area where
documentViewer.tsrenders iframes/tabs.
Uses window.addEventListener("doc-viewer-url-change", ...) to track the current URL
for the "open in new tab" button.
Step 4 — Add the route
Create frontend/src/routes/_layout/wxo.tsx:
import { createFileRoute } from "@tanstack/react-router";
import OrchestrateLayout from "@/components/watsonx/core/OrchestrateLayout";
export const Route = createFileRoute("/_layout/wxo")({
component: WxoPage,
});
function WxoPage() {
// fullscreen and docview need to fill the viewport, so override the
// parent layout's padding (mx-auto px-4 pt-20 in _layout.tsx):
return (
<div className="-mx-4 -mt-20 h-dvh pt-12">
<OrchestrateLayout />
</div>
);
}
For float and custom layouts the standard page padding is fine — remove the
negative-margin wrapper and render <OrchestrateLayout /> directly.
Step 5 — Add navigation
Read frontend/src/routes/_layout.tsx, then:
- Add
"/wxo"to theAppRouteunion type. - Add to
navItems:{ to: "/wxo", label: "Chat" }.
Step 6 — Add env variables
Add to frontend/.env and frontend/.env.example (NOT the root .env). Vite's default
envDir is the directory containing vite.config.ts — i.e. frontend/. The root .env
is only read by the manual loadEnv() call used for API_PORT/WEB_PORT in the server
config block; it does NOT populate import.meta.env. Always put VITE_* vars in
frontend/.env.
Exception: If
frontend/vite.config.tsexplicitly setsenvDir: "../", then the root.envis correct. Check before writing — never assume.
# watsonx Orchestrate embedded chat
# Values from: WXO console → agent builder → Channels → Embedded agent
VITE_WXO_ORCHESTRATION_ID=
VITE_WXO_HOST_URL=
VITE_WXO_AGENT_ID=
VITE_WXO_AGENT_ENVIRONMENT_ID=
# Layout: float | custom | fullscreen | docview
VITE_WXO_LAYOUT=<chosen-layout>
# Multi-agent: suffix all four keys with _2, _3, … for additional agents
# VITE_WXO_ORCHESTRATION_ID_2=
# VITE_WXO_HOST_URL_2=
# VITE_WXO_AGENT_ID_2=
# VITE_WXO_AGENT_ENVIRONMENT_ID_2=
# VITE_WXO_LAYOUT_2=
Step 7 — Theme sync (not scaffolded)
WXO theme sync is not scaffolded by this skill. Achieving it robustly requires a
complete set of CSS/SCSS overrides that beat WXO's own highly specific hardcoded rules
(e.g. div.WxOChatContainer.WxOChatReset.WxOChatStyles .wxo-float-header ... svg).
A partial implementation leaves individual surfaces with wrong colours and is harder
to debug than no implementation at all.
The useWatsonxTheme hook is scaffolded as a no-op placeholder. The WXO widget will
render with its own default theme regardless of the app's light/dark setting.
To implement theme sync properly:
- Obtain the full CSS override set from your WXO embed reference project.
- Stamp
cds--g100(dark) /cds--white(light) on<html>fromtheme-provider.tsx. - Apply the overrides in the app's main stylesheet, scoped to those zone classes.
Step 8 — Verify
Run pnpm typecheck inside frontend/ — must pass with no new errors.
Then tell the user:
- The chat page is at
/wxo. - Fill in the four
VITE_WXO_*variables from the WXO agent builder under Channels → Embedded agent before the widget will load. - Security is set to anonymous (
authToken = ""). To require authentication, enable the WXO security feature and replace the empty string with a real token in theauthTokenNeededcallback inOrchestrateChat.tsx. - If the
VITE_WXO_*vars show asundefinedat runtime, confirm thatfrontend/vite.config.tssetsenvDir: "../"(see Step 6 note).
Key constraints
- No new npm dependencies —
@carbon/reactand@carbon/icons-reactare already infrontend/package.json. - Theme hook — always use
resolvedThemefrom@/components/theme-provider, notactualTheme(that field does not exist in this starter). - Carbon zone classes — WXO uses
cds--g100/cds--white. The CEN Starter default (cds--g90/cds--g10) must be changed intheme-provider.tsx(Step 7) or theme sync and all SCSS overrides will be scoped to the wrong ancestor class. - Carbon tokens only — never hardcode colors; use
cds--*token classes or Tailwind mappings fromcarbon-map.css. rootElementIDalways required — set it for every layout form, including float.- Config before script —
window.wxOConfigurationmust be assigned beforeloadWxoScript()is called; never set it inside anonloadcallback. enableBodyScrollclears both — must resetoverflowon bothdocument.bodyanddocument.documentElement; resetting onlybodyis not enough.- Strict Mode guard — use a module-level
Set, notuseRef, to prevent double-init; refs are reset during React Strict Mode's unmount/remount cycle. - Multi-agent —
suffixprop on<OrchestrateLayout>routes to suffixed env vars with fallback to the unsuffixed defaults. Pass no suffix for a single agent.