@eka-care/medassist-widget-embed
Lightweight, embeddable widget loader for MedAssist using Web Components. Add the script and the <eka-medassist-widget> custom element to any page for a framework-agnostic, lazy-loaded chat widget.
Overview
- Custom Web Component –
<eka-medassist-widget>for easy embedding - Lazy loading – Widget assets load when the user opens the widget
- Framework agnostic – No React or build step required
- Isolated styling – Shadow DOM avoids conflicts with host styles
- Simple integration – Script tag + custom element
Installation
npm install @eka-care/medassist-widget-embed
Or load from a CDN (replace the version as needed):
<script
src="https://cdn.jsdelivr.net/npm/@eka-care/medassist-widget-embed@latest/dist/index.js"
async></script>
For self-hosting, ensure the built widget assets (medassist-widget.js, medassist-widget.css) are served alongside index.js, or use the package’s default CDN for widget assets (see How it works).
Usage
<!DOCTYPE html>
<html>
<head>
<title>My Website</title>
</head>
<body>
<h1>Welcome</h1>
<eka-medassist-widget agent-id="your-agent-id-here"></eka-medassist-widget>
<script src="https://cdn.jsdelivr.net/npm/@eka-care/medassist-widget-embed@latest/dist/index.js" async></script>
</body>
</html>
The widget appears as a floating button; clicking it loads and opens the chat.
Configuration
Required attributes
agent-id– Your MedAssist agent identifier
Optional attributes
icon-url– Custom icon URL for the launcher button. When omitted, the launcher uses the agent's configured icon (theme.icon_imgfrom agent config) and is only shown once that config has loaded — there is no built-in default icontitle– Widget titlebase-url– Base URL for API/agent configdisplay-mode–"widget"(floating button),"full"(inline full view) or"side_panel"(docked panel, see Side panel mode)side-panel-mode–"push"(default) or"overlay"; only used withdisplay-mode="side_panel"side-panel-width– panel width in px, clamped to 320–560 (default 400); side panel onlyside–"right"(default) or"left"; defaults to"left"on RTL documents; side panel onlyoverride-widget-width/override-widget-height– size of the floating window in px (positive numbers; anything else is ignored). Floating mode on desktop only: full page, side panel and the mobile layout keep their own sizing. Clamped to 320–800 wide and 480–900 high, and never larger than the viewport minus 20px on each side. They override the agent config'stheme.widget_width/theme.widget_height; precedence per dimension is host override → agent config → default 384×600context– JSON string of context key-value pairs
Programmatic config with EkaMedAssist.init()
After the script loads, you can set or override config before the user opens the widget:
window.EkaMedAssist.init({
agentId: "your-agent-id",
title: "MedAssist",
iconUrl: "https://example.com/icon.svg",
baseUrl: "https://api.example.com",
context: { key: "value" },
theme: {
background: "#1a1738",
primary: "#09FBD3",
textColor: "white",
overrideWidgetWidth: 480, // floating window size in px (same as the override-widget-* attributes)
overrideWidgetHeight: 720,
},
});
Custom icon example
<eka-medassist-widget
agent-id="your-agent-id"
icon-url="https://example.com/custom-icon.svg"></eka-medassist-widget>
Custom launcher styling
If you need to reposition the floating launcher button (different breakpoints, custom z-index, etc.), pass CSS via customLauncherStyles in init() — or set it directly with the custom-launcher-styles attribute. The CSS is injected into the launcher's Shadow DOM, so you can use media queries, hover states, and anything else CSS allows.
Two selectors are supported as a stable contract:
:host— the launcher wrapper. Use this to overrideposition,bottom,right,left,top,transform,z-index, etc.#medassist-open-btn— the button element itself. Use this for size, background, box-shadow, border-radius, etc.
window.EkaMedAssist.init({
agentId: "your-agent-id",
customLauncherStyles: `
@media (max-width: 991px) {
:host {
right: auto;
left: 50%;
bottom: 47px;
transform: translateX(-50%);
}
}
@media (max-width: 767px) {
:host { bottom: 34px; }
}
`,
});
This only affects the launcher button — the chat panel that opens on click is unaffected.
Side panel mode
display-mode="side_panel" docks the chat to a viewport edge at a fixed width instead of floating it. On mobile-width viewports the widget falls back to its mobile layout, as with every other mode.
<eka-medassist-widget
agent-id="your-agent-id"
display-mode="side_panel"
side-panel-mode="push"
side-panel-width="400"
side="right"></eka-medassist-widget>
side-panel-mode picks how the panel relates to your page:
overlay– the panel floats on top of the page. Nothing on your page moves.push(default) – the page is shifted aside so the panel does not cover it. On open the loader injects a stylesheet into your document<head>and adds theeka-medassist-pushedclass to<body>; on close both are removed again, leaving the page exactly as it was.
The injected stylesheet does two things:
body.eka-medassist-pushed { margin-right: 400px; transition: margin 300ms ease; } /* margin-left when side="left" */
body.eka-medassist-pushed > * { max-width: 100%; }
The margin reflows normal in-flow content. The max-width cap makes viewport-sized app roots (width: 100vw, Tailwind w-screen) that are direct children of <body> shrink with <body> instead of sliding under the panel. It does not touch position: fixed elements, and a min-width you set still wins.
If your viewport-sized root sits inside a wrapper element, the cap does not reach it. Either size it relative to its parent (w-full instead of w-screen), or subtract the panel width explicitly:
/* An app shell that must stay 100vw when the panel is closed */
.app-shell {
width: calc(100vw - var(--eka-medassist-panel-width, 0px));
transition: width 300ms ease;
}
Host analytics events
The widget never talks to GTM / gtag / dataLayer itself. The host page opts in by defining a global window.trackWidgetEvent(name, detail); the widget calls it and the host forwards whatever it wants to its own analytics stack. Without that function the widget emits nothing. detail is { sessionId, ...extra } and carries only non-sensitive identifiers — never message text, user data or phone numbers. sessionId is omitted only while a brand-new session is still being created.
Define the function before the loader script runs: events fired before it exists are dropped, not queued.
Startup
name | Extra detail | When |
|---|---|---|
session_error | { reason, status?, closeCode? } | Something kept the user from the chat. Per failure. reason is "<category>:<code>", e.g. connection_failed:network_error — see the categories below. |
connection_established | { resumed } | Session established and transport connected — the user can send a message. Once per open of the widget per session id. resumed is true when an existing session was picked up again (a reload, or a past chat opened from history), false for a freshly created one. |
session_error categories (the part of reason before the colon):
| Category | Code after the colon | When |
|---|---|---|
auth_failed | The SDK recovery reason (AUTH_EXPIRED, SESSION_FORBIDDEN) or the server's error code. status is 401/403. | A credential was rejected: token exchange or refresh failed, or the server answered 401/403. |
session_failed | The server's error code, the recovery reason (SESSION_NOT_FOUND, RETRY_LATER), an in-band code (session_not_found, token mismatch, not owned) or the SDK classification. | The session could not be created, resumed or kept. |
connection_failed | NETWORK_ERROR (fetch failed), timeout (request timed out), CONNECTION_ERROR, or a socket/stream disconnect reason (network_error, server_closed, authentication_error, timeout, max_reconnect_attempts) with closeCode. | The transport could not be reached or dropped. |
SDK errors are reported only while the chat is not connected; an error after that (feedback, history, recording) is not a startup failure.
Engagement
These answer "did the visitor interact with the bot on this visit". Like connection_established, each counts like a page view: once per open of the widget per session id, so closing the bot and opening it again fires them once more. They fire again for a resumed session — a returning user who continues an old chat counts as an interaction — and every one carries resumed (same meaning as on connection_established) so the two can be split in a funnel. No event is sent for subsequent messages.
name | Extra detail | When |
|---|---|---|
first_message_sent | { resumed } | The first message sent after the widget is opened (Enter, the send button, or a greeting suggestion), even if the resumed transcript already holds earlier messages. |
first_bot_reply | { resumed, messageId } | The first assistant reply that starts rendering after the widget is opened. The static greeting does not count. |
Starting a new chat creates a new session id, so all three milestones fire again for it with resumed: false.
Funnels: connection_established → first_message_sent → first_bot_reply measures interaction per visit regardless of whether the session was resumed; filter on resumed to compare new and returning visitors.
Event names are snake_case; the host adds its own prefix when forwarding (e.g. eka_session_error).
window.trackWidgetEvent = (name, detail) => {
window.dataLayer?.push({ event: `eka_${name}`, ...detail });
};
What push cannot move, and the hooks to handle it
position: fixed elements (sticky headers, banners, inset: 0 overlays) are positioned against the viewport, so no outside stylesheet can move them. While the panel is open the loader publishes what it did so your own CSS or JS can follow:
body.eka-medassist-pushed– class on<body>while pushed.--eka-medassist-panel-width– custom property on<html>, e.g.400px. Removed on close.--eka-medassist-panel-side– custom property on<html>,rightorleft. Removed on close.eka-medassist:side-panel-push–windowevent,detail: { pushed: boolean, width: number, side: "left" | "right" }. Fired on open (pushed: true) and close (pushed: false).window.EkaMedAssist.onSidePanelPushChange(pushed, widthPx, side)– same signal as a callback, alongsideEkaMedAssist.onClose.
Give the custom property a 0px fallback so the same rule is correct while the panel is closed:
/* A fixed header that should stop at the panel edge instead of running under it */
.site-header {
position: fixed;
top: 0;
left: 0;
right: var(--eka-medassist-panel-width, 0px);
transition: right 300ms ease;
}
window.addEventListener("eka-medassist:side-panel-push", (e) => {
const { pushed, width, side } = e.detail;
// e.g. store in app state and offset fixed chrome / recompute a canvas
});
Client tools
Give the agent functions that run in your page (read or edit your app's state). The page declares them; the backend tells the LLM they exist; when the LLM calls one, the SDK runs your run and sends the result back. Declarations follow MCP's Tool shape; only name / description / inputSchema / outputSchema / annotations leave the page — run never does.
EkaMedAssist.init({
agentId: "<agent-id>",
tools: [
{
name: "read_note",
description: "Read the note the user has open.",
inputSchema: { type: "object", properties: { section_keys: { type: "array", items: { type: "string" } } } },
annotations: { title: "Reading the note", readOnlyHint: true },
run: async (input, ctx) => {
ctx.progress({ message: "Collecting sections…" });
return { sections: getSections(input.section_keys) }; // plain object → structured result
},
},
],
});
// Replace the list at any time (before or after the widget opens):
EkaMedAssist.registerTools([...]);
Rules:
runmust read live page state at call time — tools are registered once and reused.- Return a plain object (becomes
structuredContent) or a full MCPCallToolResult. - Report a failure the model should handle by throwing an
Errorwithcode(string) and optionaldata(object); it becomes{ isError: true, structuredContent: { code, message, ...data } }. annotations.titleis the label shown on the reply bubble while the tool runs;ctx.progress({ message })updates it live.ctx.signalaborts on timeout (timeoutMs, default 30 s), server cancel, or session end.- Not supported in the iframe embed (functions cannot cross frames).
- Needs widget
0.2.x(the React-SDK build). The legacy0.1.xwidget ignorestools.
Protocol details: docs/client-tools-plan.md in the repo. Demo: test-client-tools.html.
Environments
The SDK ships with the production backend only. The environment attribute / init({ environment }) is kept for
backward compatibility but any value other than production is ignored; point at another backend with base-url /
init({ baseUrl }).
How it works
- Initial load – The script registers the custom element
<eka-medassist-widget>. - Button – The element shows a floating button (or full view if
display-mode="full"). - Lazy load – On first open, the script fetches the widget JS and CSS (from the same origin or from
https://cdn.jsdelivr.net/npm/@eka-care/medassist-widget@latest/dist/by default). - Isolation – The widget runs inside Shadow DOM to avoid style and script conflicts.
To use your own widget assets (e.g. after building @eka-care/medassist-widget), host medassist-widget.js and medassist-widget.css and point the script via the data-widget-assets attribute:
<script
src="/path/to/widget-embed/index.js"
data-widget-assets="/path/to/widget-assets/"
async></script>
File structure
widget-embed/
├── dist/
│ ├── index.js # Loader and custom element
│ ├── iframe.js # Optional iframe helper
│ └── iframe.html # Optional iframe template
├── assets/
│ └── bot-icon.svg # Sample icon for the test pages (not a runtime default)
└── README.md
Browser support
- Chrome, Firefox, Safari, Edge (latest)
- Requires Custom Elements, Shadow DOM, and ES6+
Development
Build the package:
npm run build
To build and bundle the widget assets locally:
npm run build:with-widget
(Requires the widget package to be built first.)
Integration examples
React
import { useEffect } from "react";
function App() {
useEffect(() => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/@eka-care/medassist-widget-embed@latest/dist/index.js";
script.async = true;
document.body.appendChild(script);
}, []);
return (
<div>
<h1>My App</h1>
<eka-medassist-widget agent-id="your-agent-id" />
</div>
);
}
Vue
<template>
<div>
<eka-medassist-widget :agent-id="agentId" />
</div>
</template>
<script>
export default {
data: () => ({ agentId: "your-agent-id" }),
mounted() {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/@eka-care/medassist-widget-embed@latest/dist/index.js";
script.async = true;
document.body.appendChild(script);
},
};
</script>
Troubleshooting
- Widget not appearing – Check
agent-id, ensure the loader script loads (Network tab), and check the console for errors. - Assets 404 – If using self-hosted assets, ensure
medassist-widget.jsandmedassist-widget.cssare served at the path given indata-widget-assets(or use the default CDN).
License
MIT