A bare-minimum, embeddable inline widget for Salesforce Agentforce (SCRT2 β Messaging for In-App and Web). It renders an always-visible block (not a floating popup): a text input on top and, below it, the agent's answer to your most recent question. It streams the reply token-by-token and displays it as sanitized markdown. The whole UI lives inside a Shadow DOM, so host-page styles can't leak in and the widget's styles can't leak out.
- π’ Always-visible inline block β a text input on top, the current answer below. Asking a new question replaces the previous answer (single-answer, no transcript).
- π Shadow-DOM isolated. Markdown is sanitized (
rehype-sanitize); no raw HTML injection. - π Anonymous SCRT2 session β no API key or secret in the browser.
- β»οΈ Basic resilience β SSE auto-reconnect with exponential backoff + token auto-refresh on 401.
- π¦ Ships two ways: a self-contained UMD
<script>(React bundled) and an ESM module (React as a peer).
The Salesforce SCRT2 client under
src/sdk/is a trimmed, self-contained copy carrying only the pieces this widget needs β no separate SDK dependency.
npm install inline-agent-widgetReact 18 is a peer dependency for the ESM/npm path (so your app dedupes to a single React). The UMD/CDN bundle has React bundled in and needs no peer.
The UMD bundle registers the <inline-agent-widget> custom element automatically. Just drop the tag on the page.
<script src="https://unpkg.com/inline-agent-widget@0.1.0"></script>
<inline-agent-widget
scrt2-url="https://YOUR_INSTANCE.salesforce-scrt.com"
org-id="00DXXXXXXXXXXXXXXX"
es-developer-name="YOUR_ES_DEVELOPER_NAME"
></inline-agent-widget>Pin the version (@0.1.0) so a future release can't silently change behavior on your page; drop the @version to always float to latest. Either way this resolves β via the unpkg field in package.json β to the minified UMD build (dist/inline-agent-widget.umd.js).
Importing the package runs the custom-element registration as a side effect:
import "inline-agent-widget";<inline-agent-widget
scrt2-url="https://YOUR_INSTANCE.salesforce-scrt.com"
org-id="00DXXXXXXXXXXXXXXX"
es-developer-name="YOUR_ES_DEVELOPER_NAME"
></inline-agent-widget>For when you'd rather not use the custom element (e.g. rendering into a specific node):
import { mount } from "inline-agent-widget";
const handle = mount({
elementId: "chat-root", // or: element: document.querySelector("#chat-root")
scrt2Url: "https://YOUR_INSTANCE.salesforce-scrt.com",
orgId: "00DXXXXXXXXXXXXXXX",
esDeveloperName: "YOUR_ES_DEVELOPER_NAME",
placeholder: "Ask a questionβ¦",
});
// later:
handle.unmount();If elementId isn't in the DOM yet, mount() waits for it via a MutationObserver (up to timeout ms, default 5000).
| Attribute | Required | Description |
|---|---|---|
scrt2-url |
β | SCRT2 instance base URL, e.g. https://xyz.salesforce-scrt.com. |
org-id |
β | Salesforce Organization ID. |
es-developer-name |
β | Embedded Service deployment developer name. |
capabilities-version |
Capabilities version sent with the token request (default 1). |
|
placeholder |
Placeholder text for the input (default Type a messageβ¦). |
|
product-id-param |
PDP product context (never shown). Query-string parameter to read the product id from, e.g. pid. See Product context (PDP). |
|
product-id-pattern |
PDP product context (never shown). Regex matched against the URL path; capture group 1 is the product id, e.g. /product/([^/?#]+). See Product context (PDP). |
|
enable-logging |
Present β enables [Agentforce] debug logging in the console. |
|
persist-session |
Present β persist the anonymous session so the conversation survives page navigation / reload / restart (default off), until the token's own JWT exp. Read the security note below before enabling. |
These three required values are the same non-secret identifiers Salesforce's own embedded messaging snippet uses for anonymous sessions. Connection attributes are read at mount; treat them as set-once.
The mount() API takes the camelCase equivalents (scrt2Url, orgId, esDeveloperName, capabilitiesVersion, placeholder, productIdParam, productIdPattern, enableLogging, persistSession).
When the widget sits on a Product Detail Page, it can automatically tell the agent which product the shopper is looking at β so they can ask "is this waterproof?" without naming it. Set one of the URL-extraction attributes and, on every send, the widget derives the product id from the page URL and prepends a single line to the message sent to the agent:
Viewing product details for: <productId>
<the shopper's message>
That line is never shown in the widget UI β the widget has no transcript and never renders the outgoing text, so it travels only in the request body. If no id can be resolved (neither attribute set, no match on the current URL, or not on a PDP), the message is sent unchanged.
- SFRA (
Product-Show?pid=β¦):product-id-param="pid". - PWA Kit (
/product/{id}path route):product-id-pattern="/product/([^/?#]+)".
product-id-param reads a URL query-string parameter; product-id-pattern is a regex matched against the path (location.pathname) whose first capture group is the id, and is used when the param is absent or doesn't match. The id is resolved fresh on each send, so single-page client-side navigation between products is handled without a re-mount.
Override the CSS custom properties on the element. Custom properties pierce the shadow boundary, so this works from ordinary page CSS:
inline-agent-widget {
--iaw-accent: #6b21a8;
--iaw-accent-fg: #ffffff;
--iaw-max-height: 600px;
--iaw-max-width: 420px;
--iaw-radius: 16px;
}| Property | Default | |
|---|---|---|
--iaw-font |
system UI stack | Font family. |
--iaw-bg / --iaw-fg |
#ffffff / #1a1a1a |
Surface + text. |
--iaw-border |
#e2e2e6 |
Borders. |
--iaw-radius |
12px |
Corner radius. |
--iaw-accent / --iaw-accent-fg |
#0b5cff / #fff |
Send button. |
--iaw-muted |
#6b7280 |
Secondary text / typing dots. |
--iaw-error |
#b42318 |
Error text. |
--iaw-max-height |
480px |
Max height of the response region before it scrolls. |
--iaw-max-width |
100% |
Widget max width. |
Agent output is rendered with react-markdown + remark-gfm + rehype-sanitize. Raw HTML and unsafe URIs (e.g. javascript:) are stripped β this widget deliberately does not use rehype-raw.
By default the session lives only in memory, so each full page load starts a fresh anonymous conversation. Enabling persist-session stores the session in localStorage so the conversation continues across page navigation, reload, and browser restart (and is shared across tabs of the same origin).
What's stored is the minimum: the access token, the conversation id, and the last SSE event id β never any message/transcript text. The token is anonymous and unauthenticated (the same class of credential the page already sends on every request), so if it leaks the blast radius is this messaging conversation only β it grants no access to a Salesforce login, CRM data, or any account.
Reuse is bounded by the token's own JWT exp (set by the server at mint, typically a few hours): a stored session is rehydrated only while its token is unexpired, and once expired it can't be reused (a fresh token is a new anonymous UID and would 403 on the existing conversation).
Mitigations that are always on when persistence is enabled:
- Token-expiry bound:
loadSessionrefuses and clears a stored session whose JWTexphas passed, so a dead token is never reused and doesn't linger at a well-known key. - Minimal, self-cleaning storage: only the token + conversation id + last event id are stored (never transcript text); an expired / malformed entry is proactively removed on load, and the session is cleared when the conversation ends or the SDK reports a non-recoverable
SESSION_EXPIRED.
Residual risk β read before enabling on shared/kiosk devices. localStorage is readable by any same-origin script (so an XSS or a compromised analytics/tag script on the host page could exfiltrate the token), and it persists across users of the same machine and is shared across tabs. Because reuse is bounded only by the token's exp, a stored session is resumable for the full token lifetime (potentially hours) β so on a shared or public computer, a walk-up user could resume the previous user's conversation until the token expires. For those deployments, prefer leaving persist-session off. (A per-tab, cleared-on-close sessionStorage variant would remove the shared-machine and cross-tab exposure at the cost of cross-tab/restart continuity β file an issue if you need it.) Note the shadow root is mode: "open", which is a style-isolation boundary, not a JavaScript security boundary.
π For the full architecture β module map, sequence diagrams (message flow + session persistence), and the connection state machine β see
docs/architecture.md.
- The custom element attaches an open shadow root, injects the stylesheet as an inlined string (no
<style>is added todocument.head), and mounts a React 18 root inside the shadow tree. - The session is established lazily on the first send β nothing hits the network on mount. When the user sends their first message it opens an anonymous SCRT2 session:
POST β¦/access-tokenβ open the SSE stream (GET /eventrouter/v1/ssewithAuthorization+X-Org-Id) βPOST β¦/conversation, then sends the message. Later messages reuse the live session. - Asking a question clears the previous answer and
POSTs aStaticContentMessage. The agent's reply arrives asCONVERSATION_STREAMING_TOKENevents (rendered live in the response region) and a finalCONVERSATION_MESSAGEevent whose content is authoritative and replaces the streamed preview. - If the SSE stream drops, it auto-reconnects with exponential backoff, reusing the token. Once the token expires it emits a non-recoverable
SESSION_EXPIREDerror; callconnect()again (re-mount) to start a fresh session. - With
persist-sessionon, the first send instead tries to rehydrate a stored session βclient.restore()re-opens the SSE stream with the saved token and adopts the saved conversation id, skipping the token mint and conversation creation entirely (same anonymous UID β same conversation). A returning user sees no fresh greeting because the conversation already exists. If nothing valid is stored, it falls back to the normal fresh handshake and persists the result.
npm install
npm run dev # serve demo/index.html with HMR
npm run build # ESM (dist/index.mjs) + UMD (dist/inline-agent-widget.umd.js) + dist/index.d.ts
npm run preview # build, then serve the demo against the built bundle
npm run typecheck # tsc --noEmit
npm test # vitest run (vendored SDK smoke tests + token parsing)Point demo/index.html at a real SCRT2 instance/org/ES-name to exercise the full flow. The committed values are placeholders β never commit a real org.