Skip to main content

Embedding the agent into the web chat

IBM watsonx Orchestrate allows you to embed intelligent agents directly into your web applications using the Embedded Chat feature. This integration supports secure communication, flexible UI customization, and advanced event handling to deliver rich conversational experiences.

Why embed agents?

Embedding agents into your application provides:
  • Real-time interaction: Users engage with agents directly within your app.
  • Custom UI integration: Match the agent’s look and feel to your brand.
  • Secure communication: RSA encryption and JWT authentication protect sensitive data.
  • Context-aware automation: Agents can access encrypted context variables to personalize responses.
  • Scalable deployment: Embed across multiple environments using consistent configuration.

Prerequisites

Environments: draft vs. live

  • Draft: Used for testing. Embed snippets target the draft variant.
  • Live: Available after deployment. Embed snippets default to the live variant in production.

Application requirements

Your application must:
  • Include a server (local or cloud)
  • Have an HTML element with ID root
  • Place the embed script inside the <body> tag
  • The page that embeds the web chat must use HTML’s strict mode (in clude the <!DOCTYPE html> tag)
  • Enable security
Example:
<!DOCTYPE html>
<body>
  <div id="root"></div>
  <script src="path-to-embed-script.js"></script>
</body>

Security configuration

Security is enabled by default, but must be explicitly configured. The embedded chat will not function until security is properly configured

Security Architecture

The embedded chat uses RSA public-key cryptography to secure communication. The configuration involves two key pairs:
  1. IBM Key Pair
    • Generated by: watsonx Orchestrate service.
    • Public key: Shared with your application. Your application uses this key to encrypt the user_payload section of the JWT sent to watsonx Orchestrate.
    • Private key: Stored securely by watsonx Orchestrate.cUsed to decrypt the user_payload section of the JWT.
  2. Client Key Pair
    • Generated by: You (or a security configuration tool).
    • Public key: Shared with watsonx Orchestrate. Used to verify that JWTs originate from your application.
    • Private key: Remains with you and must be stored securely. Used to sign JWTs sent to watsonx Orchestrate.
  3. JWT Authentication
    • When security is enabled, your application must:
      • Generate a JWT signed with your private key (Client Key Pair).
      • Include the JWT in all requests to the Embedded Chat API. watsonx Orchestrate validates the token using your public key.
When security is enabled:
  • All requests to the Embedded Chat API must include a valid JWT token
  • The token must be signed with your private key
  • The watsonx Orchestrate service validates the token using your public key
  • This prevents unauthorized access to your watsonx Orchestrate instance
When security is disabled:
  • Requests to the Embedded Chat API do not require authentication
  • Anyone with access to your web application where chat is Embedded can access your watsonx Orchestrate instance. In addition, your Watson Orchestrate instance allows anonymous authentication to a limited set of Apis, which is required to get your embed chat to work for anonymous users.
  • This option should only be used for specific use cases where anonymous chat access is required.
  • Ensure your watsonx Orchestrate instance in this case, does not provide access to sensitive data or access to tools configured with functional credentials that access sensitive data.

Enabling security

1

Prerequisites

  • IBM watsonx Orchestrate instance
  • API Key with administrative privileges
  • Service Instance URL from your watsonx Orchestrate instance
  • On macOS and Linux: OpenSSL installed on your system (for key generation)
  • Python Installed on your system (for key extraction from APIs)
2

Get the watsonx Orchestrate security tool

Copy the following automated script to configure security:
wxO-embed-chat-security-tool.sh
#!/usr/bin/env bash
set -euo pipefail

# ============================================================
# IBM watsonx Orchestrate — Embedded Chat Security Helper (v4)
# Single Bash script for IBM Cloud / AWS / CPD.
#
# WHAT THIS DOES
#  - Turns Embedded Chat security ON or OFF for a single instance.
#  - When turning security ON:
#      • Uses your CLIENT PUBLIC key (existing or newly generated).
#      • Asks watsonx Orchestrate for an IBM PUBLIC key.
#      • Saves the keys locally in ./keys and updates the instance config.
#  - When turning security OFF:
#      • Disables embedded chat security for that instance (anonymous access).
#  - When viewing:
#      • Only fetches and prints the current configuration (no changes).
#
# IMPORTANT:
#  - By default, Embedded Chat security is ON but not configured.
#    In that state, Embedded Chat will NOT work until keys are set,
#    or security is explicitly disabled for anonymous access.
#  - Fully configured security requires BOTH:
#      • An IBM PUBLIC key generated by watsonx Orchestrate.
#      • Your CLIENT PUBLIC key (from your own key pair).
#
# SUPPORTED INSTANCE URL FORMATS
#  IBM Cloud / AWS:
#    https://api.<host>/instances/<INSTANCE_ID>
#
#  CPD (Cloud Pak for Data):
#    https://<host>/orchestrate/<CPD_INSTANCE>/instances/<INSTANCE_ID>
#
# ARGUMENTS (optional, in order)
#   1) WXO_API_KEY            (IBM Cloud / AWS only; ignored for CPD)
#   2) FULL_INSTANCE_API_URL  (Service instance URL as above)
#
# ENVIRONMENT VARIABLES (optional)
#   ACTION=enable|disable|view    (single-shot, no main menu)
#   IAM_URL=<explicit IAM base>   (Override IAM URL for non-CPD flows)
#   WXO_API_KEY                   (Same as arg 1, if not given)
#   FULL_INSTANCE_API_URL         (Same as arg 2, if not given)
#   API_URL_WITH_INSTANCE         (Legacy name for FULL_INSTANCE_API_URL)
#   CPD_USERNAME / CPD_PASSWORD   (For CPD login, otherwise prompted)
#
# CPD TOKEN:
#   POST https://<host>/icp4d-api/v1/authorize { username, password }
#
# NON-CPD TOKEN:
#   POST ${IAM_URL}/siusermgr/api/1.0/apikeys/token { apikey }
# ============================================================

# ===============================
# Colors / formatting (best effort)
# ===============================
if [[ -t 1 ]]; then
  BOLD='\033[1m'
  GREEN='\033[0;32m'
  YELLOW='\033[0;33m'
  RED='\033[0;31m'
  BLUE='\033[0;34m'
  NC='\033[0m' # No Color
else
  BOLD='' GREEN='' YELLOW='' RED='' BLUE='' NC=''
fi

# Keep original ACTION for non-interactive single-shot runs
NON_INTERACTIVE_ACTION="${ACTION:-}"

# ===============================
# Helpers
# ===============================

banner() {
  echo "============================================================"
  echo -e "${BOLD}$1${NC}"
  echo "============================================================"
}

need_cmd() {
  if ! command -v "$1" >/dev/null 2>&1; then
    echo -e "${RED}❌ Required command '$1' not found. Please install it and retry.${NC}"
    exit 1
  fi
}

prompt_if_empty() {
  local __varname="$1"
  local __prompt="$2"
  if [[ -z "${!__varname:-}" ]]; then
    read -rp "$__prompt" "$__varname"
    export "$__varname"
  fi
}

prompt_secret_if_empty() {
  local __varname="$1"
  local __prompt="$2"
  if [[ -z "${!__varname:-}" ]]; then
    read -rs -p "$__prompt" "$__varname"
    echo
    export "$__varname"
  fi
}

# Returns base URL with any leading 'api.' stripped from the host.
strip_leading_api() {
  local in_url="$1"
  if [[ "${in_url}" =~ ^(https?://)([^/]+)(/.*)?$ ]]; then
    local scheme="${BASH_REMATCH[1]}"
    local host="${BASH_REMATCH[2]}"
    host="${host#api.}"
    echo "${scheme}${host}"
    return 0
  fi
  echo "${in_url}"
  return 0
}

# Get scheme://host from a full URL
origin_from_url() {
  local in_url="$1"
  if [[ "${in_url}" =~ ^(https?://[^/]+) ]]; then
    echo "${BASH_REMATCH[1]}"
    return 0
  fi
  echo "${in_url}"
  return 0
}

# Read a PEM file and return JSON string (jq -Rs .), normalize line endings
read_pem_as_json_string() {
  local pem_path="$1"

  if [[ ! -f "$pem_path" ]]; then
    echo -e "${RED}❌ PEM file not found: $pem_path${NC}" >&2
    return 1
  fi
  if [[ ! -r "$pem_path" ]]; then
    echo -e "${RED}❌ PEM file not readable: $pem_path${NC}" >&2
    return 1
  fi

  if grep -qE -- '-----BEGIN (RSA )?PUBLIC KEY-----' "$pem_path"; then
    : # public key — good
  elif grep -qE -- '-----BEGIN (RSA )?PRIVATE KEY-----' "$pem_path"; then
    echo -e "${YELLOW}⚠️  You provided a PRIVATE key PEM. The API expects the PUBLIC key. Continuing…${NC}" >&2
  else
    echo -e "${YELLOW}⚠️  File lacks standard PUBLIC/PRIVATE KEY PEM header. Continuing…${NC}" >&2
  fi

  tr -d '\r' < "$pem_path" | sed '${/^$/d;}' | jq -Rs .
}

# Quick help for instance URL
show_instance_help() {
  echo
  echo -e "${BOLD}How to find your Service instance URL:${NC}"
  echo "1. Open watsonx Orchestrate in your browser."
  echo "2. Click the profile icon in the top-right corner."
  echo "3. Choose \"Settings\"\"API details\"."
  echo "4. Copy the \"Service instance URL\", which looks like:"
  echo -e "   ${BLUE}https://api.us-south.watson-orchestrate.ibm.com/instances/20250807-1007-4445-5049-459a42144389${NC}"
  echo
  echo "Paste that full URL into this script when prompted."
  echo
}

# Print a human-friendly summary of the /config payload
print_config_summary() {
  local config_json="$1"

  # Detect whether is_security_enabled field exists, and preserve true/false
  local is_enabled
  is_enabled="$(
    echo "${config_json}" \
      | jq -r 'if has("is_security_enabled") then (.is_security_enabled|tostring) else "__missing__" end' 2>/dev/null \
      || echo "__missing__"
  )"

  # Extract keys
  local public_key client_public_key
  public_key="$(echo "${config_json}" | jq -r '.public_key // ""')"
  client_public_key="$(echo "${config_json}" | jq -r '.client_public_key // ""')"

  local has_client="false"
  local has_ibm="false"
  [[ -n "${client_public_key}" ]] && has_client="true"
  [[ -n "${public_key}" ]] && has_ibm="true"

  if [[ "${is_enabled}" == "__missing__" || -z "${is_enabled}" ]]; then
    echo -e "${YELLOW}Could not find is_security_enabled in the response. Raw config is shown above.${NC}"
    return 0
  fi

  # =========================
  # SECURITY DISABLED STATES
  # =========================
  if [[ "${is_enabled}" == "false" ]]; then
    echo -e "Embedded chat security is: ${YELLOW}DISABLED${NC}"

    if [[ "${has_client}" == "true" && "${has_ibm}" == "false" ]]; then
      echo "Security is disabled, but a client public key is still configured."
      echo "This may be a stale or incomplete setup."
      echo
      echo "To clean this up, you can:"
      echo "  • Replace it with the correct client public key, and when you turn security back ON,"
      echo "    generate a new IBM public key; or"
      echo "  • Clear the client public key if you intend to keep anonymous access."
      echo
      echo "When you enable security again, use the IBM public key to encrypt the user_payload"
      echo "in your identity token, and sign the token with the private key that matches your"
      echo "client public key."
      echo
      echo "Disabled security will allow anonymous users to access your embedded chat."
      echo "Anyone who can access your website will be able to open the chat without authentication."
      echo
      echo "Only use disabled security if your use case explicitly requires anonymous access and your"
      echo "watsonx Orchestrate instance does not expose sensitive data or tools configured with"
      echo "functional credentials."
    else
      echo "Anyone who can access your website can open the embedded chat without authentication."
      echo
      echo "Only use disabled security when your use case explicitly requires anonymous access and your"
      echo "watsonx Orchestrate instance does not expose sensitive data or tools configured with"
      echo "functional credentials."
    fi
    return 0
  fi

  # If we got here and it's not "true", just show raw
  if [[ "${is_enabled}" != "true" ]]; then
    echo -e "${YELLOW}Could not interpret is_security_enabled=\"${is_enabled}\". Raw config is shown above.${NC}"
    return 0
  fi

  # =========================
  # SECURITY ENABLED STATES
  # =========================
  echo -e "Embedded chat security is: ${GREEN}ENABLED${NC}"

  if [[ "${has_client}" == "true" && "${has_ibm}" == "true" ]]; then
    # Fully configured: security ON + both keys present
    echo -e "${GREEN}Embedded chat security is enabled and configured.${NC}"
    echo "Security is enabled and both IBM and client key pairs are configured."
    echo
    echo "Update your embed script to pass a valid RS256-signed JWT using the private key"
    echo "that matches your client public key, so watsonx Orchestrate can authenticate users"
    echo "and allow embedded chat to work as expected."

  elif [[ "${has_client}" == "true" && "${has_ibm}" == "false" ]]; then
    # Security ON + client key present + IBM key missing
    echo -e "${GREEN}Embedded chat security is enabled (encryption not configured).${NC}"
    echo "A client public key is configured, but Encrypt sensitive information is not set up."
    echo
    echo "Generate an IBM public key so you can encrypt the user_payload in your identity token"
    echo "and fully enable encrypted embedded chat security."

  elif [[ "${has_client}" == "false" && "${has_ibm}" == "true" ]]; then
    # Security ON + IBM key present + client key missing
    echo -e "${YELLOW}Embedded chat security is enabled but the client public key is missing.${NC}"
    echo "A client public key is required to validate your identity tokens."
    echo
    echo "Paste a valid client public key in the Chat user identity section and update"
    echo "your configuration so watsonx Orchestrate can verify your RS256-signed JWTs."

  elif [[ "${has_client}" == "false" && "${has_ibm}" == "false" ]]; then
    # Security ON + no keys at all
    echo -e "${YELLOW}Embedded chat security is enabled but not fully configured.${NC}"
    echo "Security is currently turned on, but embedded chat will not work until both key"
    echo "pairs are configured:"
    echo "  • An IBM public key generated by watsonx Orchestrate."
    echo "  • Your application’s client public key used to verify signed tokens."
    echo
    echo "To complete configuration, we recommend the following steps:"
    echo "  1) Provide a valid client public key in the Chat user identity section."
    echo "  2) Generate an IBM public key for Encrypt sensitive information."
    echo "  3) Update your embed script to use the IBM public key to encrypt user_payload."
  else
    # Catch-all for any other unusual combination
    echo -e "${YELLOW}Security is enabled, but the key configuration is incomplete or unusual.${NC}"
    echo "Check that both of the following are set correctly:"
    echo "  • IBM public key (for encrypting user_payload)."
    echo "  • Client public key (for verifying your signed JWTs)."
  fi
}

# ===============================
# Dependencies
# ===============================
need_cmd curl
need_cmd jq
need_cmd sed
need_cmd openssl
need_cmd tr
need_cmd grep

# ===============================
# Welcome + optional help
# ===============================
banner "IBM watsonx Orchestrate — Embedded Chat Security Helper (v4)"
echo "This helper manages embedded chat security for a single watsonx Orchestrate instance."
echo
echo "By default, embedded chat security is ON but not configured."
echo "In that state, embedded chat will not work until:"
echo "  • both IBM and client key pairs are configured, or"
echo "  • security is explicitly turned OFF to allow anonymous access."
echo

read -rp "Do you need help finding your Service instance URL? (y/N): " NEED_HELP
NEED_HELP="${NEED_HELP:-n}"
if [[ "${NEED_HELP}" == [Yy] ]]; then
  show_instance_help
fi

# ===============================
# 1) Inputs (URL first), then creds
# ===============================
WXO_API_KEY="${1:-${WXO_API_KEY:-}}"
FULL_INSTANCE_API_URL="${2:-${FULL_INSTANCE_API_URL:-${API_URL_WITH_INSTANCE:-}}}"

if [[ -z "${FULL_INSTANCE_API_URL}" ]]; then
  banner "watsonx Orchestrate Embedded Chat Security"
  echo "Please provide the Service instance URL from the API details page:"
  echo "  • IBM Cloud / AWS:"
  echo "      https://api.<host>/instances/<INSTANCE_ID>"
  echo "  • CPD:"
  echo "      https://<host>/orchestrate/<CPD_INSTANCE>/instances/<INSTANCE_ID>"
  echo
fi

prompt_if_empty FULL_INSTANCE_API_URL "Enter Service instance URL: "

# ===============================
# 2) Parse URL, detect CPD vs Non-CPD
# ===============================
IS_CPD="false"
API_ORIGIN="$(origin_from_url "${FULL_INSTANCE_API_URL}")"

# CPD pattern
if [[ "${FULL_INSTANCE_API_URL}" =~ ^(https?://[^/]+)/orchestrate/([^/]+)/instances/([^/?#]+) ]]; then
  IS_CPD="true"
  API_URL_BASE="${BASH_REMATCH[1]}/orchestrate/${BASH_REMATCH[2]}"
  CPD_INSTANCE_NAME="${BASH_REMATCH[2]}"
  WXO_INSTANCE_ID="${BASH_REMATCH[3]}"
# Non-CPD pattern
elif [[ "${FULL_INSTANCE_API_URL}" =~ ^(https?://[^/]+)/instances/([^/?#]+) ]]; then
  IS_CPD="false"
  API_URL_BASE="${BASH_REMATCH[1]}"
  WXO_INSTANCE_ID="${BASH_REMATCH[2]}"
else
  echo -e "${RED}❌ Invalid Service instance URL.${NC}"
  echo "   IBM Cloud / AWS example:"
  echo "     https://api.us-south.watson-orchestrate.ibm.com/instances/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  echo "   CPD example:"
  echo "     https://cpd-cpd-instance-1.apps.example.com/orchestrate/cpd-instance-1/instances/1757991870301390"
  echo "Got: ${FULL_INSTANCE_API_URL}"
  exit 1
fi

# Heuristic: also mark CPD if host contains 'cpd' (defensive)
if [[ "${API_ORIGIN}" == *"cpd"* ]]; then
  IS_CPD="true"
fi

banner "Inputs"
echo "WXO_INSTANCE_ID:   ${WXO_INSTANCE_ID}"
echo "API_URL_BASE:      ${API_URL_BASE}"
echo "API_ORIGIN:        ${API_ORIGIN}"
echo "Detected Platform: $([[ "${IS_CPD}" == "true" ]] && echo "CPD" || echo "IBM Cloud / AWS")"
echo

# ===============================
# 3) Token acquisition (once)
# ===============================
if [[ "${IS_CPD}" == "true" ]]; then
  # CPD: username + password -> token at <origin>/icp4d-api/v1/authorize
  banner "Fetching CPD token"
  CPD_USERNAME="${CPD_USERNAME:-}"
  CPD_PASSWORD="${CPD_PASSWORD:-}"
  prompt_if_empty CPD_USERNAME "Enter CPD Username: "
  prompt_secret_if_empty CPD_PASSWORD "Enter CPD Password (hidden): "

  WXO_TOKEN="$(
    curl --fail -sS \
      --request POST \
      --url "${API_ORIGIN}/icp4d-api/v1/authorize" \
      --header 'Content-Type: application/json' \
      --header 'cache-control: no-cache' \
      --data "{\"username\":\"${CPD_USERNAME}\",\"password\":\"${CPD_PASSWORD}\"}" --insecure \
    | jq -r .token
  )"

  if [[ -z "${WXO_TOKEN}" || "${WXO_TOKEN}" == "null" ]]; then
    echo -e "${RED}❌ Failed to fetch CPD token. Check your credentials and host.${NC}"
    exit 1
  fi
  echo -e "${GREEN}✅ CPD token acquired.${NC}"
  echo

else
  # IBM Cloud / AWS: API key -> IAM token selection
  if [[ -z "${WXO_API_KEY}" ]]; then
    echo "IBM Cloud / AWS flow selected."
    echo "You will be prompted for WXO_API_KEY (input hidden)."
  fi

  prompt_secret_if_empty WXO_API_KEY "Enter WXO_API_KEY (hidden): "

  # Auto-select IAM_URL unless provided
  if [[ -z "${IAM_URL:-}" ]]; then
    API_URL_NOAPI="$(strip_leading_api "${API_URL_BASE}")"
    case "${API_URL_NOAPI}" in
      # IBM Cloud - PROD
      https://au-syd.watson-orchestrate.cloud.ibm.com|\
      https://jp-tok.watson-orchestrate.cloud.ibm.com|\
      https://eu-de.watson-orchestrate.cloud.ibm.com|\
      https://eu-gb.watson-orchestrate.cloud.ibm.com|\
      https://us-south.watson-orchestrate.cloud.ibm.com|\
      https://us-east.watson-orchestrate.cloud.ibm.com|\
      https://ca-tor.watson-orchestrate.cloud.ibm.com)
        IAM_URL="https://iam.platform.saas.ibm.com"
        ;;
      # AWS - PROD
      https://ap-southeast-1.dl.watson-orchestrate.ibm.com|\
      https://ap-south-1.dl.watson-orchestrate.ibm.com|\
      https://eu-central-1.dl.watson-orchestrate.ibm.com|\
      https://dl.watson-orchestrate.ibm.com|\
      https://cio.watson-orchestrate.ibm.com)
        IAM_URL="https://iam.platform.saas.ibm.com"
        ;;
      # GovCloud (mapped to PROD IAM)
      https://origin-us-gov-east-1.watson-orchestrate.prep.ibmforusgov.com)
        IAM_URL="https://iam.platform.saas.ibm.com"
        ;;
      # STAGING / TEST
      https://us-south.watson-orchestrate.test.cloud.ibm.com|\
      https://perfus-south.watson-orchestrate.test.cloud.ibm.com|\
      https://staging-wa.watson-orchestrate.ibm.com|\
      https://wodlperf.watson-orchestrate.ibm.com|\
      https://preprod.dl.watson-orchestrate.ibm.com)
        IAM_URL="https://iam.platform.test.saas.ibm.com"
        ;;
      # DEV
      https://us-south.watson-orchestrate-dev.test.cloud.ibm.com|\
      https://dev-wa.watson-orchestrate.ibm.com|\
      https://dev-fedtest.watson-orchestrate.ibm.com)
        IAM_URL="https://iam.platform.dev.saas.ibm.com"
        ;;
      *)
        echo -e "${YELLOW}⚠️  Unknown API host after normalization: ${API_URL_NOAPI}${NC}"
        echo "   Falling back to PROD IAM."
        IAM_URL="https://iam.platform.saas.ibm.com"
        ;;
    esac

    export IAM_URL
    banner "IAM URL selected"
    echo "Normalized API host: ${API_URL_NOAPI}"
    echo "IAM_URL:             ${IAM_URL}"
    echo
  else
    banner "IAM URL selected (from environment)"
    echo "IAM_URL: ${IAM_URL}"
    echo
  fi

  banner "Fetching IAM token"
  WXO_TOKEN="$(
    curl --fail -sS \
      --request POST \
      --url "${IAM_URL}/siusermgr/api/1.0/apikeys/token" \
      --header 'accept: application/json' \
      --header 'content-type: application/json' \
      --data "{\"apikey\": \"${WXO_API_KEY}\"}" \
    | jq -r .token
  )"

  if [[ -z "${WXO_TOKEN}" || "${WXO_TOKEN}" == "null" ]]; then
    echo -e "${RED}❌ Failed to fetch IAM token. Check your WXO_API_KEY and IAM_URL.${NC}"
    exit 1
  fi
  echo -e "${GREEN}✅ Token acquired.${NC}"
  echo
fi

# ===============================
# 4) Endpoints (CPD vs Non-CPD)
# ===============================
if [[ "${IS_CPD}" == "true" ]]; then
  CONFIG_ENDPOINT="${API_URL_BASE}/instances/${WXO_INSTANCE_ID}/v1/embed/secure/config"
  GENKEY_ENDPOINT="${API_URL_BASE}/instances/${WXO_INSTANCE_ID}/v1/embed/secure/generate-key-pair"
else
  CONFIG_ENDPOINT="${API_URL_BASE}/instances/${WXO_INSTANCE_ID}/v1/embed/secure/config"
  GENKEY_ENDPOINT="${API_URL_BASE}/instances/${WXO_INSTANCE_ID}/v1/embed/secure/generate-key-pair"
fi

# ===============================
# 5) Common action runner (enable/disable/view + verify)
# ===============================
run_action() {
  local action="$1"

  if [[ "${action}" == "enable" ]]; then
    banner "Turn ON embedded chat security and configure keys"

    mkdir -p keys

    echo "Choose how to provide the client public key:"
    echo "  1) Use an existing PUBLIC key PEM (path on disk)"
    echo "  2) Generate a NEW RSA 4096 key pair now"
    read -rp "Enter 1 or 2: " key_choice

    local CLIENT_PUBLIC_KEY_JSON=""
    local KEY_CHOICE="${key_choice}"

    case "$key_choice" in
      1)
        read -rp "Enter path to client PUBLIC key PEM (e.g., /path/to/jwtRS256.key.pub): " CLIENT_PUB_PEM_PATH
        CLIENT_PUBLIC_KEY_JSON="$(read_pem_as_json_string "$CLIENT_PUB_PEM_PATH")"
        ;;
      2)
        echo "Generating client RSA key pair in ./keys ..."
        openssl genrsa -out keys/example-jwtRS256.key 4096 >/dev/null 2>&1
        openssl rsa -in keys/example-jwtRS256.key -pubout -out keys/example-jwtRS256.key.pub >/dev/null 2>&1
        CLIENT_PUBLIC_KEY_JSON="$(read_pem_as_json_string "keys/example-jwtRS256.key.pub")"
        echo "🔐 Generated client key files:"
        echo "  • keys/example-jwtRS256.key        (client PRIVATE key — keep this secure)"
        echo "  • keys/example-jwtRS256.key.pub    (client PUBLIC key)"
        ;;
      *)
        echo -e "${RED}❌ Invalid choice.${NC}"
        return 1
        ;;
    esac

    if [[ -z "${CLIENT_PUBLIC_KEY_JSON:-}" || "${CLIENT_PUBLIC_KEY_JSON}" == "null" ]]; then
      echo -e "${RED}❌ Could not prepare client PUBLIC key for payload.${NC}"
      return 1
    fi

    echo "Requesting IBM public key from watsonx Orchestrate..."
    curl --fail -sS \
      --request POST \
      --url "${GENKEY_ENDPOINT}" \
      --header "Authorization: Bearer ${WXO_TOKEN}" --insecure \
    | jq -r '.public_key' > keys/ibmPublic.key.pub

    if [[ ! -s keys/ibmPublic.key.pub ]]; then
      echo -e "${RED}❌ Failed to retrieve IBM public key.${NC}"
      return 1
    fi

    local IBM_PUBLIC_KEY_JSON
    IBM_PUBLIC_KEY_JSON="$(read_pem_as_json_string "keys/ibmPublic.key.pub")"

    local CONFIG_PAYLOAD
    CONFIG_PAYLOAD=$(
      cat <<JSON
{
  "public_key": ${IBM_PUBLIC_KEY_JSON},
  "client_public_key": ${CLIENT_PUBLIC_KEY_JSON},
  "is_security_enabled": true
}
JSON
    )

    echo "Updating embedded chat security configuration..."
    local RESULT
    RESULT="$(
      curl --fail -sS \
        --request POST \
        --url "${CONFIG_ENDPOINT}" \
        --header "Authorization: Bearer ${WXO_TOKEN}" \
        --header 'Content-Type: application/json' \
        --data "${CONFIG_PAYLOAD}" --insecure
    )"

    echo -e "${GREEN}✅ Embedded chat security has been turned ON and keys are configured (server response below).${NC}"
    echo "Server response:"
    echo "${RESULT}" | jq .
    echo

    echo "Saved IBM PUBLIC key to: keys/ibmPublic.key.pub"
    if [[ "${KEY_CHOICE}" == "1" ]]; then
      echo "Use this IBM public key to encrypt the user_payload in your identity token."
    else
      echo "Use:"
      echo "  • keys/example-jwtRS256.key        (client PRIVATE key for signing JWTs)"
      echo "  • keys/example-jwtRS256.key.pub    (client PUBLIC key configured on the instance)"
      echo "  • keys/ibmPublic.key.pub           (IBM PUBLIC key for encrypting user_payload)"
    fi
    echo

  elif [[ "${action}" == "disable" ]]; then
    # For interactive runs (no NON_INTERACTIVE_ACTION), ask for confirmation
    if [[ -z "${NON_INTERACTIVE_ACTION}" ]]; then
      echo
      echo -e "${RED}Disable embedded chat security?${NC}"
      echo "Disabling security will allow anonymous users to access your embedded chat."
      echo "Anyone who can access your website will be able to open the chat without authentication."
      echo
      echo "Only disable security if your use case explicitly requires anonymous access and your"
      echo "watsonx Orchestrate instance does not expose sensitive data or tools configured with"
      echo "functional credentials."
      echo
      read -rp "Type 'yes' to confirm disabling security: " CONFIRM_DISABLE
      if [[ "${CONFIRM_DISABLE}" != "yes" ]]; then
        echo "Operation cancelled. Security settings were not changed."
        return 0
      fi
    fi

    banner "Turn OFF embedded chat security (anonymous access)"

    local DISABLE_PAYLOAD
    DISABLE_PAYLOAD='{"is_security_enabled": false}'

    echo "Updating embedded chat security configuration..."
    local RESULT
    RESULT="$(
      curl --fail -sS \
        --request POST \
        --url "${CONFIG_ENDPOINT}" \
        --header "Authorization: Bearer ${WXO_TOKEN}" \
        --header 'Content-Type: application/json' \
        --data "${DISABLE_PAYLOAD}" --insecure
    )"

    echo -e "${YELLOW}✅ Embedded chat security has been turned OFF for this instance.${NC}"
    echo "Server response:"
    echo "${RESULT}" | jq .
    echo

  else
    banner "View current embedded chat security configuration (read-only)"
  fi

  # Common: GET /config and summary
  banner "Check current configuration"
  local VERIFY
  VERIFY="$(
    curl --fail -sS \
      --request GET \
      --url "${CONFIG_ENDPOINT}" \
      --header "Authorization: Bearer ${WXO_TOKEN}" \
      --header 'Accept: application/json' --insecure
  )"
  echo "Raw configuration from the service:"
  echo "${VERIFY}" | jq .
  echo
  echo "Summary:"
  print_config_summary "${VERIFY}"
  echo
  echo -e "${GREEN}Action completed.${NC}"
}

# ===============================
# 6) Non-interactive (ACTION set) or interactive main menu loop
# ===============================
if [[ -n "${NON_INTERACTIVE_ACTION}" ]]; then
  # Single-shot mode for automation: just run the given action and exit.
  run_action "${NON_INTERACTIVE_ACTION}"
  exit 0
fi

# Interactive main menu loop
while true; do
  echo
  echo "What would you like to do for this instance?"
  echo "  1) Turn ON embedded chat security and configure keys (recommended)"
  echo "  2) Turn OFF embedded chat security and allow anonymous access"
  echo "  3) View current embedded chat security configuration (no changes)"
  echo "  4) Exit"
  read -rp "Enter 1, 2, 3 or 4: " choice

  case "$choice" in
    1)
      run_action "enable"
      ;;
    2)
      run_action "disable"
      ;;
    3)
      run_action "view"
      ;;
    4)
      echo "Exiting."
      break
      ;;
    *)
      echo -e "${RED}❌ Invalid choice. Please enter 1, 2, 3 or 4.${NC}"
      ;;
  esac
done
3

Change the script's permissions

On Unix-based systems (macOS and Linux), change the permissions to run the script:
chmod +x wxO-embed-chat-security-tool.sh
4

Run the script

Run the script and follow the instructions to enable or disable security:
./wxO-embed-chat-security-tool.sh
After you configure security:
  1. The tool generates an IBM key pair via the API
  2. The tool generates a client key pair using OpenSSL
  3. Both public keys are configured in the service
  4. Security is enabled
All keys are saved in the wxo_security_config directory:
  • ibm_public_key.pem: IBM’s public key in PEM format
  • ibm_public_key.txt: IBM’s public key in single-line format
  • client_private_key.pem: Your private key (keep it secure!)
  • client_public_key.pem: Your public key in PEM format
  • client_public_key.txt: Your public key in single-line format

Anonymous Access

You may disable security for anonymous access, but only if:
  • No sensitive data is exposed.
  • No tools with functional credentials are accessible.

How to embed

Use the CLI command to generate the embed script:
orchestrate channels webchat embed --agent-name=test_agent1
This outputs a <script> tag with configuration for your environment. You can add this script to an HTML page on your website. The following is an example of the command’s output:
OUTPUT
<script>
  window.wxOConfiguration = {
    orchestrationID: "your-orgID_orchestrationID", // Adds control over chat display mode (e.g., fullscreen)
    hostURL: "https://dl.watson-orchestrate.ibm.com", // or region-specific host
    rootElementID: "root",
    showLauncher: false,
    deploymentPlatform: "ibmcloud", // Required for IBM Cloud embed, can be skipped for other embed scenarios
    crn: "your-org-crn", // Required for IBM Cloud embed, can be skipped for other embed scenarios. For more information, see [FAQs](https://www.ibm.com/docs/en/watsonx/watson-orchestrate/base?topic=experience-faqs#how-can-i-know-my-crn-id)
    chatOptions: {
      agentId: "your-agent-id",
      agentEnvironmentId: "your-agent-env-id",
    },
    layout: {
      form: "fullscreen-overlay", // Options: float | custom | fullscreen-overlay - if not specified float is default
      showOrchestrateHeader: true, // Optional: shows top agent header bar
      width: "600px", // Optional: honored when form is float only
      height: "600px", // Optional: honored when form is float only
    },
  };

  setTimeout(function () {
    const script = document.createElement("script");
    script.src = `${window.wxOConfiguration.hostURL}/wxochat/wxoLoader.js?embed=true`;
    script.addEventListener("load", function () {
      wxoLoader.init();
    });
    document.head.appendChild(script);
  }, 0);
</script>

Example implementation

The following is a minimal implementation example of a server and website running the web chat with security and authentication configured. This implementation uses NodeJS with the Express web application framework. The project uses the following folder structure:
.
└── webchatProject/
    ├── keys/
    │   ├── example-jwtRS256.key
    │   ├── example-jwtRS256.key.pub
    │   └── ibmPublic.key.pub
    ├── routes/
    │   └── createJWT.js
    ├── static/
    │   └── index.html
    ├── server.js
    └── package.json
You can generate the example-jwtRS256 keys with the following commands:
ssh-keygen -t rsa -b 4096 -m PEM -f example-jwtRS256.key
openssl rsa -in example-jwtRS256.key -pubout -outform PEM -out example-jwtRS256.key.pub
And obtain the IBM public key by running the script provided in Enabling security. Use the following file contents as a reference for your implementation:
/**
 * JWT Creation Service for watsonx Orchestrate Secure Embed Chat
 *
 * This module handles the server-side creation of JSON Web Tokens (JWTs) for secure
 * authentication with IBM watsonx Orchestrate embed chat. It demonstrates:
 *
 * 1. RS256 JWT signing using your private key
 * 2. Optional user payload encryption using IBM's public key
 * 3. Cookie-based anonymous user tracking
 * 4. Session-based user information enrichment
 *
 * Security Notes:
 * - Never expose your private key to the client side
 * - Always generate JWTs server-side only
 * - Use HTTPS in production environments
 * - Adjust token expiration times based on your security requirements
 */

const fs = require("fs");
const path = require("path");
const express = require("express");
const { v4: uuid } = require("uuid");
const jwtLib = require("jsonwebtoken");
const NodeRSA = require("node-rsa");

const router = express.Router();

/**
 * Load your server-side RSA private key (PEM format)
 *
 * This key is used to sign JWTs with the RS256 algorithm. The corresponding public key
 * must be uploaded to your watsonx Orchestrate instance security settings.
 *
 * IMPORTANT: Keep this file secure and never expose it to clients!
 *
 * Generate a new key pair using either method:
 *
 * Method 1 - Using ssh-keygen:
 *   ssh-keygen -t rsa -b 4096 -m PEM -f example-jwtRS256.key
 *   openssl rsa -in example-jwtRS256.key -pubout -outform PEM -out example-jwtRS256.key.pub
 *
 * Method 2 - Using openssl directly:
 *   openssl genrsa -out example-jwtRS256.key 4096
 *   openssl rsa -in example-jwtRS256.key -pubout -out example-jwtRS256.key.pub
 */
const PRIVATE_KEY_PATH = path.join(__dirname, "../keys/example-jwtRS256.key");
if (!fs.existsSync(PRIVATE_KEY_PATH)) {
  throw new Error(`Private key not found at ${PRIVATE_KEY_PATH}`);
}
const PRIVATE_KEY = fs.readFileSync(PRIVATE_KEY_PATH);

/**
 * Load IBM's RSA public key (PEM format)
 *
 * This key is provided by IBM watsonx Orchestrate and is used to encrypt the user_payload
 * field in the JWT. This ensures that sensitive user information cannot be read by clients,
 * as only IBM's servers can decrypt it.
 *
 * You can obtain this key by:
 * 1. Running the wxo-embed-security-v4.sh script (recommended)
 * 2. Calling the generate-key-pair API endpoint manually
 *
 * The encrypted payload will be decrypted server-side by watsonx Orchestrate.
 */
const IBM_PUBLIC_KEY_PATH = path.join(__dirname, "../keys/ibmPublic.key.pub");
if (!fs.existsSync(IBM_PUBLIC_KEY_PATH)) {
  throw new Error(`IBM public key not found at ${IBM_PUBLIC_KEY_PATH}`);
}
const IBM_PUBLIC_KEY = fs.readFileSync(IBM_PUBLIC_KEY_PATH);

/**
 * Cookie lifetime configuration
 *
 * This defines how long the anonymous user ID cookie will persist.
 * Currently set to 60ms for demo purposes - in production, use a longer duration
 * such as 45 days (45 * 24 * 60 * 60 * 1000 milliseconds).
 */
const TIME_45_DAYS = 60; // Demo value - replace with: 45 * 24 * 60 * 60 * 1000 for production

/**
 * Create a signed JWT string for the wxO embed chat client
 *
 * This function constructs a JWT with the following structure:
 *
 * @param {string} anonymousUserID - A stable identifier for anonymous users (from cookie)
 * @param {object|null} sessionInfo - Optional authenticated user session data
 *
 * JWT Claims:
 * - sub: Subject (user identifier) - should be a stable, unique ID for the user
 * - user_payload: Encrypted user data that will be decrypted by watsonx Orchestrate
 *   - name: User's display name
 *   - custom_message: Any custom message or metadata
 *   - custom_user_id: Your application's internal user ID
 *   - sso_token: Single sign-on token if applicable
 * - context: Additional context data accessible by the agent
 *   - wxo_clientID: Your client/organization identifier
 *   - wxo_name: User's name for display in chat
 *   - wxo_role: User's role (e.g., Admin, User, Guest)
 *
 * @returns {string} A signed JWT token string
 */
function createJWTString(anonymousUserID, sessionInfo) {
  // Base JWT claims structure
  // Customize these fields based on your application's requirements
  const jwtContent = {
    // Subject: Unique identifier for the user
    // In production, use a real user ID from your authentication system
    sub: "FHYU235DD5", // Example value - replace with actual user ID
    
    // User payload: Will be encrypted with IBM's public key
    // This data is sensitive and will only be readable by watsonx Orchestrate servers
    user_payload: {
      name: "Anonymous",
      custom_message: "Encrypted message",
      custom_user_id: "",
      sso_token: "sso_token",
    },
    
    // Context: Additional metadata accessible by the agent
    // This data is NOT encrypted and can be read by the client
    context: {
      wxo_clientID: "865511",      // Your client/organization ID
      wxo_name: "Ava",             // Display name in chat
      wxo_role: "Admin",           // User role
    },
  };

  // Enrich the JWT with authenticated session data if available
  // In production, this would come from your authentication system
  if (sessionInfo) {
    jwtContent.user_payload.name = sessionInfo.userName;
    jwtContent.user_payload.custom_user_id = sessionInfo.customUserID;
  }

  // Encrypt the user_payload using IBM's RSA public key
  // This ensures sensitive user data cannot be read by clients
  // Only watsonx Orchestrate servers can decrypt this data
  if (jwtContent.user_payload) {
    const rsaKey = new NodeRSA(IBM_PUBLIC_KEY);
    const dataString = JSON.stringify(jwtContent.user_payload);
    const utf8Data = Buffer.from(dataString, "utf-8");
    // Encrypt and encode as base64 string
    jwtContent.user_payload = rsaKey.encrypt(utf8Data, "base64");
  }

  // Sign the JWT using RS256 algorithm with your private key
  // The token expiration should be set based on your security requirements
  // Common values: "1h" (1 hour), "6h" (6 hours), "1d" (1 day)
  const jwtString = jwtLib.sign(jwtContent, PRIVATE_KEY, {
    algorithm: "RS256",
    expiresIn: "10000000s", // Demo value - use shorter duration in production (e.g., "1h")
  });

  return jwtString;
}

/**
 * Retrieve or create a stable anonymous user ID stored in a cookie
 *
 * This function ensures that anonymous users maintain a consistent identity across
 * page refreshes and sessions. The ID is stored in a secure HTTP-only cookie.
 *
 * Benefits:
 * - Prevents user identity from changing mid-session
 * - Enables conversation continuity for anonymous users
 * - Provides basic user tracking without requiring authentication
 *
 * @param {object} request - Express request object
 * @param {object} response - Express response object
 * @returns {string} The anonymous user ID
 */
function getOrSetAnonymousID(request, response) {
  // Check if an anonymous ID already exists in cookies
  let anonymousID = request.cookies["ANONYMOUS-USER-ID"];
  
  // Generate a new ID if none exists
  if (!anonymousID) {
    // Create a short, readable ID using UUID (first 5 characters for demo)
    // In production, you might want to use the full UUID for better uniqueness
    anonymousID = `anon-${uuid().slice(0, 5)}`;
  }

  // Set/refresh the cookie with each request to maintain the session
  response.cookie("ANONYMOUS-USER-ID", anonymousID, {
    expires: new Date(Date.now() + TIME_45_DAYS),
    httpOnly: true,  // Prevents client-side JavaScript from accessing the cookie (security)
    sameSite: "Lax", // Provides CSRF protection while allowing normal navigation
    secure: false,   // Set to true in production when using HTTPS
  });

  return anonymousID;
}

/**
 * Parse authenticated session information from cookies
 *
 * This function retrieves user session data if the user is authenticated.
 * In a production application, you would:
 * 1. Verify the session token/cookie
 * 2. Fetch user information from your database or identity provider
 * 3. Validate user permissions
 *
 * For this demo, we simply parse a JSON string from a cookie.
 *
 * @param {object} request - Express request object
 * @returns {object|null} Session info object or null if not authenticated
 */
function getSessionInfo(request) {
  const sessionInfo = request.cookies?.SESSION_INFO;
  if (!sessionInfo) return null;
  
  try {
    // Parse the JSON session data
    return JSON.parse(sessionInfo);
  } catch {
    // Return null if parsing fails (invalid JSON)
    return null;
  }
}

/**
 * Express route handler for JWT creation
 *
 * This endpoint is called by the client to obtain a fresh JWT token.
 * The token is required for secure authentication with watsonx Orchestrate embed chat.
 *
 * Flow:
 * 1. Retrieve or create an anonymous user ID (stored in cookie)
 * 2. Check for authenticated session information
 * 3. Generate a signed JWT with user data
 * 4. Return the JWT as plain text response
 *
 * The client will include this JWT in the wxOConfiguration.token field
 * when initializing the embed chat.
 *
 * @param {object} request - Express request object
 * @param {object} response - Express response object
 */
function createJWT(request, response) {
  // Ensure we have a stable user ID (anonymous or authenticated)
  const anonymousUserID = getOrSetAnonymousID(request, response);
  
  // Get authenticated session data if available
  const sessionInfo = getSessionInfo(request);

  // Create and sign the JWT
  const token = createJWTString(anonymousUserID, sessionInfo);
  
  // Return the JWT as plain text
  response.send(token);
}

// Define the GET endpoint that returns a signed JWT string
// This endpoint is called by the client before initializing the chat
router.get("/", createJWT);

module.exports = router;

Context variables

To use context variables:
  1. Enable context_access_enabled: true in your agent definition.
  2. Add variables like channel to the context_variables in your agent definition file.
  3. Reimport the agent.
  4. Include context variables in the JWT payload.
spec_version: v1
style: react
name: hello_agent
llm: watsonx/meta-llama/llama-3-1-70b-instruct
description:  'Agent description'
instructions: |
  You are a helpful agent that must answer user questions in a clean and concise manner. Be polite and do not perform harmful behavior.
collaborators: []
tools: []
context_access_enabled: true
context_variables:
  - channel     # Use it to get access to context variables on the embedded chat
You can add context variables to a JWT token using a JavaScript script. The following script shows how to include context variables inside a JWT token in your server:
createJWT
const fs = require('fs');
const RSA = require('node-rsa');
const crypto = require('crypto');
const jwtLib = require('jsonwebtoken');
const express = require('express');
const path = require('path');
const { v4: uuid } = require('uuid');

const router = express.Router();

// This is your private key that you will keep on your server. This is used to sign the jwt. You will paste your public
// key into the appropriate field on the Security tab of the web chat settings page.
// This public key is used to validate the signature on the jwt.
const PRIVATE_KEY = fs.readFileSync(path.join(__dirname, 'wxo_security_config/client_private_key.pem'));

//The code below will use this key to encrypt the user payload inside the JWT.
const PUBLIC_KEY = fs.readFileSync(path.join(__dirname, 'wxo_security_config/client_public_key.pem'));


// A time period of 45 days in milliseconds.
const TIME_45_DAYS = 1000 * 60 * 60 * 24 * 45;

/**
 * Generates a signed JWT. The JWT used here will always be assigned a user ID using the given anonymous user ID. If
 * the user is authenticated and we have session info, then info about the user will also be added to the JWT.
 * Always use the anonymous user ID even if the user is authenticated because changing the user ID in the middle of
 * a session is not allowed.
 */
function createJWTString(anonymousUserID, sessionInfo,context) {
  // This is the content of the JWT. You would normally look up the user information from a user profile.
  const jwtContent = {
    // This is the subject of the JWT which will be the ID of the user.
    //
    // This user ID will be available under integrations.channel.private.user.id in dialog and
    // system_integrations.channel.private.user.id in actions.
    sub: anonymousUserID,
    // This object is optional and contains any data you wish to include as part of the JWT. This data will be
    // encrypted using the public key so it will not be visible to your users.
    user_payload: {
      custom_message: 'Encrypted message',
      name: 'Anonymous',
    },
    context
  };

  // If the user is authenticated, then add the user's real info to the JWT.
  if (sessionInfo) {
    jwtContent.user_payload.name = sessionInfo.userName;
    jwtContent.user_payload.custom_user_id = sessionInfo.customUserID;
  }

  const dataString = JSON.stringify(jwtContent.user_payload);

  // Encrypt the data
  const encryptedBuffer = crypto.publicEncrypt(
    {
      key: PUBLIC_KEY,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: 'sha256'  // Specify OAEP padding with SHA256
    },
    Buffer.from(dataString, 'utf-8')
  );

  // Convert encrypted data to base64
  jwtContent.user_payload = encryptedBuffer.toString('base64');
  console.log(jwtContent.user_payload)


  // Now sign the jwt content to make the actual jwt. We are giving this a very short expiration time (10 seconds)
  // to demonstrate the web chat capability of fetching a new token when it expires. In a production environment,
  // you would likely want to set this to a much higher value or leave it out entirely.
  const jwtString = jwtLib.sign(jwtContent, PRIVATE_KEY, {
    algorithm: 'RS256',
    expiresIn: '10000000s',
  });

  return jwtString;
}

/**
 * Gets or sets the anonymous user ID cookie. This will also ensure that an existing cookie is updated with a new 45
 * day expiration time.
 */
function getOrSetAnonymousID(request, response) {
  let anonymousID = request.cookies['ANONYMOUS-USER-ID'];
  if (!anonymousID) {
    // If we don't already have an anonymous user ID, then create one. Normally you would want to use a full UUID,
    // but for the sake of this example we are going to shorten it to just five characters to make them easier to read.
    anonymousID = `anon-${uuid().substr(0, 5)}`;
  }

  // Here we set the value of the cookie and give it an expiration date of 45 days. We do this even if we already
  // have an ID to make sure that we update the expiration date to a new 45 days.
  response.cookie('ANONYMOUS-USER-ID', anonymousID, {
    expires: new Date(Date.now() + TIME_45_DAYS),
    httpOnly: true,
  });

  return anonymousID;
}

/**
 * Returns the session info for an authenticated user.
 */
function getSessionInfo(request) {
  // Normally the cookie would contain a session token that we would use to look up the user's info from something
  // like a database. But for the sake of simplicity in this example the session cookie directly contains the user's
  // info.
  const sessionInfo = request.cookies.SESSION_INFO;
  if (sessionInfo) {
    return JSON.parse(sessionInfo);
  }
  return null;
}

/**
 * Handles the createJWT request.
 */
function createJWT(request, response) {
  const anonymousUserID = getOrSetAnonymousID(request, response);
  const sessionInfo = getSessionInfo(request);

  const context = {
    dev_id: 23424,
    dev_name: "Name",
    is_active: true
  }

  response.send(createJWTString(anonymousUserID, sessionInfo,context));
}

router.get('/', createJWT);

module.exports = router;
You can check the examples for watsonx Assistant web chat that are mostly compatible with the watsonx Orchestrate embedded chat to see how to use this code example After generating the JWT token, pass it to the embedded web chat. The following example shows how to do that:
JavaScript
<script>
    function getUserId() {
        let embed_user_id = getCookie('embed_user_id');
        if (!embed_user_id) {
            embed_user_id = Math.trunc(Math.random() * 1000000);
            setCookie('embed_user_id', embed_user_id);
        }
        return embed_user_id;
    }

    function getCookie(name) {
        console.log('getCookie');
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        if (parts.length === 2) return parts.pop().split(';').shift();
    }

    function setCookie(name, value) {
        document.cookie = `${name}=${value}; path=/`;
    }
    function preSendHandler(event) {
        if (event?.message?.content) {
            event.message.content = event.message.content.toUpperCase();
        }
    }

    function sendHandler(event) {
        console.log('send event', event);
    }

    function feedbackHandler(event) {
        console.log('feedback', event);
    }

    function preReceiveHandler(event) {
        event?.content?.map((element) => {
            element.type = 'date';
        });
    }

    function receiveHandler(event) {
        console.log('received event', event);
    }

    function userDefinedResponseHandler(event) {
        console.log('userDefinedResponse event', event);
        event.hostElement.innerHTML = `
                        <cds-code-snippet>
                            node -v Lorem ipsum dolor sit amet, consectetur adipisicing elit. Blanditiis,
                            veritatis voluptate id incidunt molestiae officia possimus, quasi itaque
                            alias, architecto hic, dicta fugit? Debitis delectus quidem explicabo vitae
                            laboriosam!
                        </cds-code-snippet>
                        <br><br>
                        <div style="background-color:orange;color:white;padding:10px;">
                            <p>${event.contentItem?.template || '[No message content]'}</p>
                        </div>`;
    }

    function onChatLoad(instance) {
        instance.on('chatstarted', (instance) => {
            window.wxoChatInstance = instance;
        });
        instance.on('pre:send', preSendHandler);
        instance.on('send', sendHandler);
        instance.on('pre:receive', preReceiveHandler);
        instance.on('receive', receiveHandler);
        instance.on('feedback', feedbackHandler);
        instance.on('userDefinedResponse', userDefinedResponseHandler);
    }
    async function getIdentityToken() {
        // This will make a call to your server to request a new JWT.
        const result = await fetch(
            "http://localhost:3000/createJWT?user_id=" + getUserId()
        );
        window.wxOConfiguration.token = await result.text();
    }

    window.wxOConfiguration = {
        orchestrationID: "20250430-0912-2925-309a-35c6bef54760_20250430-0949-0287-00f2-33dc583100c9",
        hostURL: "https://us-south.watson-orchestrate.cloud.ibm.com",
        rootElementID: "root",
        deploymentPlatform: "ibmcloud",
        crn: "crn:v1:bluemix:public:watsonx-orchestrate:us-south:a/123-456::",
        chatOptions: {
            agentId: "852431a8-32dd-4925-8cc3-9ea3d3162726",
            agentEnvironmentId: "5d769a04-9445-4768-a687-710d6e9a24cf",
            onLoad: onChatLoad
        },
    };
    getIdentityToken().then(() => {
        const script = document.createElement("script");
        script.src = `${window.wxOConfiguration.hostURL}/wxochat/wxoLoader.js?embed=true`;
        script.addEventListener("load", function () {
            wxoLoader.init();
        });
        document.head.appendChild(script);
    });
</script>

Customizing the embedded chat

Header is an optional property in wxOConfiguration that controls whether header actions appear.
ParameterTypeDescription
header.showResetButtonbooleanDisplays the Reset Chat button in the header when set to true. Default is true.
header.showAiDisclaimerbooleanDisplays the AI disclaimer icon/button in the header when set to true. Default is true.

Language

ParameterTypeDescription
defaultLocalestringDefines the default language supported by the chat. Supported values: de, en, es, fr, it, ja, and pt-BR

Styles

You can customize embedded web chats to create a unique chat interface that better fits your webpage. To apply custom styles, add a style component inside the window.wxOConfiguration object in your web chat script. In this component, you can configure the following elements: Header is an optional property of wxOConfiguration that controls the visibility of header actions.
ParameterTypeDescription
headerColorstringSet a six-digit hex code that defines the chat header color.
userMessageBackgroundColorstringSet a six-digit hex code that defines the user message bubble color.
primaryColorstringSet a six-digit hex code that defines the interactive elements color.
showBackgroundGradientbooleanDisplays the background gradient when set to true. Default is true.
The following is an example of how to customize the embedded web chat using the style component inside window.wxOConfiguration:
<script>
window.wxOConfiguration = {
  orchestrationID: "my-tenant-id",
  hostURL: "my-host-url",
  rootElementID: "root",            // fullscreen-overlay only
  showLauncher: false,              // fullscreen-overlay only, false = direct render, true = launcher bubble
  chatOptions: {
    agentId: "12345_test_agent1",            // required
    agentEnvironmentId: "my-agent-env-id"    // required
  },
  header: {
    showResetButton: true,   // optional; defaults to true
    showAiDisclaimer: true   // optional; defaults to true
  },
   style: {
   headerColor: '', //6-digit hex value or empty for default
   userMessageBackgroundColor: '', //6-digit hex value or empty for default
   primaryColor: '', //6-digit hex value or empty for default
   showBackgroundGradient: true, // optional; defaults to true
  },
};

setTimeout(function() {
  const script = document.createElement('script');
  script.src = `${window.wxOConfiguration.hostURL}/wxochat/wxoLoader.js?embed=true`;
  script.addEventListener('load', () => wxoLoader.init());
  document.head.appendChild(script);
}, 0);
</script>

Layout

The watsonx Orchestrate embed supports a flexible layout object to control how and where the chat UI appears.
ParameterTypeDefaultDescription
rootElementIDstring(fullscreen-overlay only) ID of the container node to mount chat into.
showLauncherbooleantrue(fullscreen-overlay only) Show the bubble launcher (true) or render chat immediately (false).
layout.formstringfloatDefines the layout form of your web chat.

Use fullscreen-overlay to display the web chat in fullscreen mode. No additional parameters are required.

Use float to display the web chat as a floating window. Also configure:
  • width: Width of the web chat
  • height: Height of the web chat
Use custom to define a custom layout. Also configure the customElement parameter with your custom element.
layout.widthstring(float only) Popup width (e.g. ‘350px’, ‘30rem’).
layout.heightstring(float only) Popup height (e.g. ‘500px’, ‘40rem’).
layout.showOrchestrateHeaderbooleantrueRender the standard header bar (true) or hide it (false).
layout.customElementHTMLElementelement reference to render into.
<script>
window.wxOConfiguration = {
  orchestrationID: "my-tenant-id",
  hostURL: "my-host-url",
  rootElementID: "root",            // fullscreen-overlay only
  showLauncher: false,              // fullscreen-overlay only, false = direct render, true = launcher bubble

  chatOptions: {
    agentId: "12345_test_agent1",            // required
    agentEnvironmentId: "my-agent-env-id"    // required
  },

  layout: {
    form: 'float',                           // 'fullscreen-overlay' | 'float' | 'custom'
    width: '600px',                          // float only
    height: '600px',                         // float only
    showOrchestrateHeader: true,            // hide header if false
    customElement: hostElement              // custom only
  }
};

setTimeout(function() {
  const script = document.createElement('script');
  script.src = `${window.wxOConfiguration.hostURL}/wxochat/wxoLoader.js?embed=true`;
  script.addEventListener('load', () => wxoLoader.init());
  document.head.appendChild(script);
}, 0);
</script>
The following is an example of how to customize the layout of the embedded web chat to display it in fullscreen mode:
JavaScript
<script>
window.wxOConfiguration = {
  orchestrationID: "my-tenant-id",
  hostURL: "my-host-url",
  rootElementID: "root",
  showLauncher: false,
  chatOptions: {
    agentId: "test_agent1",
    agentEnvironmentId: "my-agent-draft-env-id"
  },

  layout:{
    form: 'fullscreen-overlay',
    showOrchestrateHeader: true,
  }

};

setTimeout(function() {
  const script = document.createElement('script');
  script.src = `${window.wxOConfiguration.hostURL}/wxochat/wxoLoader.js?embed=true`;
  script.addEventListener('load', () => wxoLoader.init());
  document.head.appendChild(script);
}, 0);
</script>

Feedback, events and instance methods

Thumbs-up and thumbs-down feedback

In the embedded chat, you need to manually enable thumbs-up and thumbs-down feedback using pre:receive handlers. First, subscribe to the pre:receive event to inject feedback options. Then, handle submitted feedback through the feedback event. The following script shows how to configure feedback in the embedded chat:
JavaScript
<script>
function feedbackHandler(event) {
    console.log('feedback', event);
}

function preReceiveHandler(event) {
    console.log('pre-receive event', event);
    const lastItem = event?.message?.content?.[event.message.content.length - 1];
    if (lastItem) {
        lastItem.message_options = {
            feedback: {
                is_on: true,
                show_positive_details: false,
                show_negative_details: true,
                // Note, these positive details are not used as long as show_positive_details is false.
                positive_options: {
                    categories: ['Funny', 'Helpful', 'Correct'],
                    disclaimer: "Provide content that can be shared publicly.",
                },
                negative_options: {
                    categories: ['Inaccurate', 'Incomplete', 'Too long', 'Irrelevant', 'Other'],
                    disclaimer: "Provide content that can be shared publicly.",
                },
            },
        };
    }
}

function onChatLoad(instance) {
    instance.on('pre:receive', preReceiveHandler);
    instance.on('feedback', feedbackHandler);
}

window.wxOConfiguration = {
    ...
        };
setTimeout(function () {
            ...
        }, 0);
</script>
watsonx Orchestrate does not persist feedback internally. You are responsible for storage and analysis. If you want to store feedback on your backend, you can capture feedback data and send it to your own backend API:
sendFeedbackToBackend.js
instance.on('feedback', (feedbackEvent) => {
  fetch('/api/store-feedback', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${jwtToken}`
    },
    body: JSON.stringify({
      feedback: feedbackEvent.feedback,
      messageId: feedbackEvent.messageId,
      sessionId: feedbackEvent.sessionId
    })
  });
});

Events

Embedded web chat supports a variety of events that allow you to trigger specific actions or customize behavior. The following tables list all supported events, grouped by category.

Customization Events

Event nameDescription
userDefinedResponseTriggered when a response contains an unrecognized or user_defined response type.

Message Events

Event nameDescription
pre:receiveTriggered before the web chat receives a response from the agent.
pre:sendTriggered before the web chat sends a message to the agent.
receiveTriggered after the web chat receives a response from the agent.
sendTriggered after the web chat sends a message to the agent.
pre:restartConversationTriggered before the conversation restarts. Useful for alerting the user that the chat will reset, allowing them to complete any ongoing actions (e.g., finishing a tool call).
restartConversationTriggered after the conversation restarts, before a new session begins. Useful for displaying specific UI elements when a new session starts.

View Events

Event nameDescription
view:pre:changeTriggered before the view state changes.
view:changeTriggered after the view state changes.

Security Events

Event nameDescription
identityTokenExpiredTriggered when security is enabled and the JWT token expires.

Miscellaneous Events

Event nameDescription
chat:readyTriggered when the web chat is fully loaded and ready to receive user input. Useful for displaying a welcome message or initializing UI components.

Instance methods

While events are used to listen to the embedded chat, instance methods are used to take actions on the embedded chat. The following tables list all supported instance methods, grouped by category.

Message

Event nameDescription
doAutoScrollScrolls to the most recent message in the list.
scrollToMessageScrolls the messages list to a specific message.
sendSends the specified message to the agent.
updateHistoryUserDefinedUpdates a user_defined property in message history. Need to store the user_defined in the message_state column of the messages table so that it will appear in the history
restartConversationRestarts the conversation with the agent.

User Interface

Event nameDescription
changeViewChanges the current view state.
updateLocaleChanges the display language. Supported values: de, en, es, fr, it, ja, and pt-BR.

Security/Identity

Event nameDescription
updateIdentityTokenReplaces the current JWT with a new one for continued secure communication. This is commonly used when tokens expire or are refreshed during a session.
destroyDestroys the web chat and removes it from the page.

Events

Event nameDescription
onceSubscribes a handler function to an event so it runs only once when that event occurs. After the event fires, the handler is no longer called.
offRemoves a subscription to an event type.
onSubscribes to a type of event.

Events and instance methods example

The following example shows how to configure events and instance methods in the embedded web chat:
JavaScript
    <script>
        function preSendHandler(event, instance) {
            console.log('pre:send event', event);
            if (event?.message?.message?.content) {
                event.message.message.content = event.message.message?.content.toUpperCase();
            }
        }

        function sendHandler(event, instance) {
            console.log('send event', event);
        }

        function feedbackHandler(event, instance) {
            console.log('feedback', event);
        }

        function preReceiveHandler(event, instance) {
            console.log('pre-receive event', event);
            event?.message?.content?.map((element) => {
                if (element?.text?.includes('assistant')) {
                    element.text = element.text.replace('assistant', 'Agent');
                }
                element.type = 'user_defined';
            });

            const lastItem = event?.message?.content?.[event.message?.content.length - 1];
            if (lastItem) {
                lastItem.message_options = {
                    feedback: {
                        is_on: true,
                        show_positive_details: false,
                        show_negative_details: true,

                        positive_options: {
                            categories: ['Funny', 'Helpful', 'Correct'],
                            disclaimer: "Provide content that can be shared publicly.",
                        },
                        negative_options: {
                            categories: ['Inaccurate', 'Incomplete', 'Too long', 'Irrelevant', 'Other'],
                            disclaimer: "Provide content that can be shared publicly.",
                        },
                    },
                };
            }
        }

        function receiveHandler(event, instance) {
            console.log('received event', event);
            instance.off('pre:receive', preReceiveHandler);
            instance.updateAuthToken("wrong-or-expired-token")
        }

        function userDefinedResponseHandler(event, instance) {
            console.log('userDefinedResponse event', event);
            event.hostElement.innerHTML = `
                    <cds-code-snippet>
                        node -v Lorem ipsum dolor sit amet, consectetur adipisicing elit. Blanditiis,
                        veritatis voluptate id incidunt molestiae officia possimus, quasi itaque
                        alias, architecto hic, dicta fugit? Debitis delectus quidem explicabo vitae
                        laboriosam!
                    </cds-code-snippet>
                    <br><br>
                    <div style="background-color:orange;color:white;padding:10px;">
                        <p>${event.contentItem?.text || '[No message content]'}</p>
                    </div>`;
        }

        function preRestartConversationHandler(event, instance) {
            console.log('pre:restartConversation event', event);
        }

        let calledRestartConversation = false;
        function restartConversationHandler(event, instance) {
            console.log('restartConversationHandler event', event);
            if (!calledRestartConversation) {
                setTimeout(() => {
                    instance.send('Hello from embedded web chat second time')
                }, 3000);
                calledRestartConversation = true;
            }

        }

        function preThreadLoadedHandler(event, instance) {
            console.log('pre:threadLoaded event', event);
            // event.messages[0].content[0].text = 'Modified prompt in thread history';
            event?.messages.forEach((message) => {
                if (message?.sender === 'response') {
                    const [lastItem] = message.content;
                    lastItem.message_options = {
                        feedback: {
                            is_on: true,
                        },
                    };
                    message.message_state = {
                        content: {
                            1: {
                                feedback: {
                                    text: "",
                                    is_positive: true,
                                    selected_categories: []
                                }
                            }
                        }
                    };
                }
            });
        }

        async function authTokenNeededHandler(event, instance) {
            console.log('authTokenNeeded event', event);
            event.authToken = "<Refreshed Token>"
        }

        function onChatLoad(instance) {
            instance.on('chat:ready', (event, instance) => {
                console.log('chat:ready', event);
            });
            instance.once('pre:send', preSendHandler);
            instance.on('send', sendHandler);
            instance.once('pre:receive', preReceiveHandler);
            instance.on('receive', receiveHandler);
            instance.on('feedback', feedbackHandler);
            instance.on('userDefinedResponse', userDefinedResponseHandler);
            instance.on('pre:restartConversation', preRestartConversationHandler);
            instance.on('restartConversation', restartConversationHandler);
            instance.on('pre:threadLoaded', preThreadLoadedHandler);
            instance.on('authTokenNeeded', authTokenNeededHandler);
        }
        window.wxOConfiguration = {
            clientVersion: 'latest',
            orchestrationID: '<tenantId>',
            hostUrl: 'http://localhost:3000',
            showLauncher: true,
            rootElementId: 'root',
            chatOptions: {
                agentId: '<agentId>',
                agentEnvironmentId: '<agentEnvironmentId>',
                onLoad: onChatLoad,
            },
        };
        setTimeout(function () {
            const script = document.createElement('script');
            script.src = `${window.wxOConfiguration.hostUrl}/wxoLoader.js?embed=true`;
            script.addEventListener('load', function () {
                wxoLoader.init();
            });
            document.head.appendChild(script);
        }, 0);
    </script>

API Integration

You can also integrate your agent with external applications by using the ADK’s provided agent completions API. These APIs allow agents to be shared across multiple watsonx Orchestrate instances: