Marco widget · Technical documentation
The Marco widget installs in three different ways, depending on where your application lives and where the session data has to stay. This guide covers all three. Pick your flow and follow only that column: each one is self-contained.
Step zero
The question that settles it is where the session is created and what the browser receives. Everything else follows from that.
Flow A
Sites with no authentication. Two HTML tags and nothing more: the session starts on its own. Trust rests on the domain origin and the appId.
Flow B
Your frontend already manages authenticated users. You start the session with init() and an identity your backend signs with HMAC. No endpoint of your own.
Flow C
The clinical context never touches the browser. Your backend creates the session over the API and the frontend only receives an opaque, single-use token.
| Public page | Private page | Server to server | |
|---|---|---|---|
| Who creates the session | The script, automatically | Your frontend, via init() |
Your backend, over the API |
| What the browser receives | The appId only |
user_id, signature and metadata |
An opaque token only |
| Authentication | Origin + appId |
HMAC-SHA256 signature | Bearer client_secret |
| Embedding | Script + <marco> element |
Script + JavaScript API | <iframe> with the token in the URL |
| Backend required | No | For signing only | Yes |
| Clinical context | Not applicable | Passes through the browser, signed | Never leaves the server |
| Inline rendering | No, always floating | Yes, via component_id |
Yes, you control the iframe |
| Resumable sessions | Yes, via localStorage |
Yes, by user_id/external_id |
No, by design |
State names are identical across all three flows
All three publish state names in upper case (READY, SESSION_CREATED, STARTED), so a handler written for one flow can be reused in another without normalising. What changes between flows is which states exist, not how they are spelled.
Guide by flow
The session is identified by origin (domain) and appId, both configured with Marco in advance. The widget loads from a single script tag and starts by itself, without calling any JavaScript API.
appId.Add these two tags to your page, one in the head and one in the body. That's all: no other call is required.
<html>
<head>
<script
src="https://production.deep-talk.ai/widget/widget.js"
defer>
</script>
</head>
<body>
<marco
id="my-marco-widget"
api-base-url="https://production.deep-talk.ai/widget"
app-id="YOUR_APP_ID">
</marco>
</body>
</html>
Replace YOUR_APP_ID with the identifier Marco gives you. Loading the script is enough: the widget appears automatically as a floating bubble.
Configuration lives in Marco's backend
Brand colours, behaviour and other settings are configured once with your Marco contact and stay tied to your appId. A change is applied on Marco's side and shows up on your page automatically, with no redeploy. The script URL is static too: product fixes arrive on their own.
The session is created as soon as the widget finishes loading. Each browser keeps one continuous conversation, identified by its own session_id.
The widget generates a random session identifier the first time it loads in a browser and stores it in localStorage. It is not tied to any real identity: it only allows the conversation to resume in that same browser, and serves as a correlation identifier for events and auditing.
Reloading the page, navigating away and back, or closing and reopening the tab or browser (same profile): in all those cases the widget finds the stored identifier and, if the session is still within its inactivity timeout, resumes the conversation and publishes SESSION_RELOADED instead of SESSION_CREATED.
A session idle beyond its configured limit closes automatically. The next time the widget loads in that browser there is no session left to resume, and it publishes SESSION_CREATED.
By default the session only ends through inactivity or a user action inside the widget. If you need to close it programmatically, use MarcoWidget.finish().
For most integrations, the section above is all you need. If your application has to react to events or close the session explicitly, the script exposes an optional global namespace, MarcoWidget, on window.
MarcoWidget.on('READY', function () {
console.log('Marco widget ready');
});
MarcoWidget.on('SESSION_CREATED', function (msg) {
console.log('New session:', msg.session_id);
});
// Ends the session and clears the identifier from localStorage.
// The next load starts a fresh session.
MarcoWidget.finish();
MarcoWidget.on() subscribes a callback to state events and handles origin verification for you. In this flow the session is already active by the time the script loads, so MarcoWidget is only for listening to events and, optionally, closing the session.
The widget publishes session state with postMessage, emitted from its iframe to the containing window. MarcoWidget.on() is a wrapper over that same stream.
| State | Published when |
|---|---|
READY |
The script has finished loading. The session is still being created. |
SESSION_CREATED |
A new session was created for this browser. |
SESSION_RELOADED |
An existing active session was found and resumed in this browser. |
STARTED |
The visitor sent a query and the interaction is under way. |
RESPONSE_CREATED |
The agent produced a new response within the active session. |
COMPLETED |
The session ended normally. Includes created_at and finished_at. |
CANCELLED |
Ended early: visitor action, finish(), or inactivity. |
ERROR |
The session or the call in progress ended because of a failure. |
Terminal states
COMPLETED, CANCELLED and ERROR are terminal. None of them can be followed by further activity in the same session, and the next load of the widget will publish SESSION_CREATED.
READY, STARTED, CANCELLED and ERROR share this base shape. session_id is absent on READY and present from SESSION_CREATED or SESSION_RELOADED onwards.
{
"source": "marco-widget",
"version": 1,
"type": "STATE_CHANGE",
"state": "READY",
"session_id": "sess_9f2a1c",
"timestamp": "2026-08-20T14:32:04Z"
}
COMPLETED adds two fields on top of the base: created_at and finished_at.
Errors arrive as an ERROR state event, in the same shape as above, with code and message fields.
| Code | Usual cause |
|---|---|
unauthorized_origin |
The request came from an origin outside the allowlist for this appId. |
invalid_app_id |
The appId matches no configured account, or was omitted. |
rate_limited |
The request limit for this appId or IP address was exceeded. |
network_error |
The request to Marco's backend failed: connectivity or timeout. |
Origin is this flow's primary trust signal. Every widget load is matched against the domains authorised for the given appId, and any request from an origin outside that list is rejected with unauthorized_origin. Marco also applies rate limits per appId and per IP address.
The script and its calls are served over HTTPS only, and your application must be served over HTTPS in every environment. The widget runs inside a sandboxed iframe with minimum permissions, managed automatically by the loader script.
If your application enforces a Content-Security-Policy, allow the widget domain in the three directives that govern script loading, iframe embedding and the widget's network calls.
Content-Security-Policy:
script-src https://production.deep-talk.ai;
frame-src https://production.deep-talk.ai;
connect-src 'self' https://production.deep-talk.ai;
Your frontend starts and manages the session directly, calling MarcoWidget with a user identity signed by your backend. The browser receives the signature, never the secret.
component_id.The clinical context passes through the browser
The metadata object travels through the browser on its way to Marco, signed together with the user's identity. Avoid including directly identifying information: name, date of birth or medical record number. If the context cannot leave your server, use the server-to-server flow.
This tag loads the widget shell. The session is started separately, with the call to init. No visible interface is rendered until then.
<html lang="en">
<head>
<meta charset="utf-8">
<script src="https://production.deep-talk.ai/widget/widget.js"
defer onload="initializeMarco()"></script>
</head>
<body>
<script>
const signature = '<SIGNATURE_FROM_BACKEND>';
const timestamp = '<TIMESTAMP_FROM_BACKEND>';
function initializeMarco() {
window.$MarcoWidget.init({
apiBaseUrl: 'https://production.deep-talk.ai/widget',
appId: 'marco-demo',
floating: true,
user_id: '323232',
created_at: timestamp,
signature: signature,
});
}
</script>
</body>
</html>
init(), typically in the same place where you already handle authentication: a successful-login handler, a React effect, an auth callback.init() again with the new external_id.finish(), which closes the active session and clears the locally stored data.Your backend computes an HMAC-SHA256 signature locally using a shared secret issued by Marco, and your frontend sends it along with the user_id. Treat the secret like a server-side API key: read it from your secrets manager and never send it to the frontend, to a mobile client, or to a third party.
// Base formula
signature = HMAC-SHA256(shared_secret, user_id)
// With replay protection (recommended in production)
signature = HMAC-SHA256(shared_secret, `${user_id}:${created_at}`)
// Node.js
const crypto = require('crypto');
const signature = crypto
.createHmac('sha256', sharedSecret) // secret, as UTF-8
.update(userId, 'utf8') // user_id, as UTF-8
.digest('hex'); // lower-case hexadecimal
Both the secret and the user_id must be encoded as UTF-8 before signing, and the resulting signature must be represented in lower-case hexadecimal. A signature that does not follow this exact encoding fails verification even when the secret is correct.
By default the signature only proves that the user_id was signed by someone holding your secret, not when. Passing an optional created_at closes that gap. It is a UTC timestamp in ISO 8601 format and, when included, must be part of both the payload and the signature. Marco rejects calls whose created_at is more than five minutes away from the current time, in either direction.
Key handling
Marco generates the shared secret and hands it to you. Keep it on your backend only: it must not appear in frontend code, browser storage, or any request payload. Each environment has its own secret. If you suspect one has been exposed, contact Marco to rotate it immediately.
| Field | Used in | Required | Description |
|---|---|---|---|
user_id |
init, finish |
Yes | Your own identifier for the end user. |
signature |
init, finish |
Yes | HMAC-SHA256 signature of the user_id, and of created_at when included. |
created_at |
init, finish |
No | UTC ISO 8601 timestamp for replay protection. Recommended in production. |
external_id |
init |
No | Identifies the open context, for example the patient record. Omit it for a single global conversation per user_id; provide it consistently if the same user should see a different conversation per context. |
component_id |
init |
No | Id of the DOM element where Marco should render inline. If omitted, it renders floating. |
metadata |
init |
No | Free-form JSON passed to the agent session as context. There is no fixed schema. |
{
"specialty": "oncology",
"diagnosis": "HER2-positive breast cancer, stage III",
"current_treatment": "trastuzumab + pertuzumab, cycle 4 of 6",
"disease_state": "partial response, stable since last imaging",
"ecog_status": "1",
"line_of_therapy": "second line"
}
Prefer descriptive, self-explanatory keys over abbreviations. metadata is interpreted by the agent when the session starts, so clarity here translates directly into better answers. The example is illustrative: longer text or summaries also work if that represents your data better.
Two independent lifetimes apply: the signature's validity window and the session itself.
When created_at is included, the signature is valid for five minutes from issue. It is recomputed, or reused within its window, on every call to init or finish.
Runs for an independently configured lifetime once started. It extends with activity and ends through an explicit close, inactivity, or expiry of that lifetime.
Sessions can be resumed
The widget stores an identifier for the active session in localStorage, keyed by user_id and external_id. When your application calls init() again with the same pair, Marco looks for an active session. If one exists and is still within its inactivity timeout, the conversation is restored and SESSION_RELOADED is published instead of SESSION_CREATED.
To point the widget at a different patient or record, call init() with the new external_id. This does not update the open session: it is a distinct session, new or resumed if that context already had an active one.
All three behave the same way: the iframe is discarded, but the identifier persists in localStorage. The next call to init() with the same user_id and external_id attempts to resume automatically, as an effect of that same call.
MarcoWidget.finish() closes the session immediately and clears the stored identifier. After finish(), a call to init() with the same pair starts a fresh session.
The widget publishes state with postMessage from its iframe to the containing window. The loader script also exposes MarcoWidget.on(event, callback), a wrapper over that same stream that handles origin verification for you.
MarcoWidget.on('READY', function () {
console.log('Marco widget ready to receive init()');
});
MarcoWidget.on('SESSION_CREATED', function (msg) {
console.log('New session:', msg.session_id);
});
MarcoWidget.on('ERROR', function (err) {
console.error('Marco widget error:', err.code, err.message);
});
| State | Published when |
|---|---|
READY |
The script has loaded and is ready to receive init(). No session exists yet. |
SESSION_CREATED |
init() created a new session for the given user_id/external_id. |
SESSION_RELOADED |
init() found and resumed an active session for the same pair. |
STARTED |
The user sent a query and the interaction is under way. |
RESPONSE_CREATED |
The agent produced a new response within the active session. |
COMPLETED |
Ended normally. Includes created_at, finished_at and, optionally, metadata. |
CANCELLED |
Ended early: user action, finish(), or inactivity. |
ERROR |
The session or the call in progress ended because of a failure. |
session_id and external_id are absent on READY and present from SESSION_CREATED or SESSION_RELOADED onwards.
{
"source": "marco-widget",
"version": 1,
"type": "STATE_CHANGE",
"state": "SESSION_RELOADED",
"session_id": "sess_9f2a1c",
"external_id": "patient_789",
"timestamp": "2026-08-20T14:32:04Z"
}
The signature is computed locally on your backend, with no round trip to Marco. An invalid or expired signature is discovered when the widget tries to use it, and arrives as an ERROR state event.
| Code | Usual cause |
|---|---|
invalid_signature |
The signature does not match the value Marco recomputes for the given payload. |
expired_timestamp |
created_at was included but falls outside the five-minute window. |
missing_required_field |
user_id or signature was missing from the call. |
session_not_found |
No matching active session, for example calling finish() on one that already ended. |
component_not_found |
component_id was included but matches no element present in the DOM. |
unauthorized_origin |
The request came from an origin outside your allowlist. |
network_error |
The request to Marco's backend failed: connectivity or timeout. |
The user_id is never trusted on its own. Marco recomputes the signature independently and compares it before starting or continuing any session. Every call to init or finish is also matched against the domains authorised for your account, and rejected with unauthorized_origin if the origin is not on the list, even when the signature is valid.
A valid signature confirms that your backend, holding the secret, signed that user_id. Checking whether that user should have access to the record described in metadata is your own application's responsibility, before calling init(). The access decision is made at that moment; the signature only carries it.
Content-Security-Policy:
script-src https://production.deep-talk.ai; // bootstrap script
frame-src https://production.deep-talk.ai; // sandboxed iframe
connect-src 'self' https://production.deep-talk.ai; // widget calls
The script and its calls are served over HTTPS only. The widget runs in a sandboxed iframe with minimum permissions, and uses localStorage for resuming; that cache is cleared when you call finish().
Your backend creates the session directly with Marco's backend. Your frontend never receives any session-scoped data, only an opaque, single-use, short-lived token to start the chat.
client_secret, server to server.<iframe> you control.Your backend calls Marco's session endpoint over HTTPS. The credential is presented as a bearer token and is long-lived: it is issued once per client, not per session.
POST /launch_token
Host: production.deep-talk.ai
Authorization: Bearer <client_secret>
Content-Type: application/json
{
"app_id": "oncology-widget",
"user_id": "doctor_123",
"metadata": {
"specialty": "oncology",
"diagnosis": "HER2-positive breast cancer, stage III",
"current_treatment": "trastuzumab + pertuzumab, cycle 4 of 6"
}
}
| Field | Required | Description |
|---|---|---|
app_id |
Yes | Identifies which Marco app or agent configuration this session uses. Assigned by Marco during onboarding. |
user_id |
No | Your own identifier for the end user. Use an internal ID, not a name or an email. Useful for usage statistics, but it can be omitted. |
metadata |
No | Free-form JSON describing the session context. Used server-side only. |
{
"launch_token": "lt_7Kx0mQe2vB…",
"session_id": "sess_9f2a1c",
"expires_at": "2026-08-20T14:37:00Z"
}
launch_token is opaque, single-use and short-lived, and carries none of the session's underlying data. Keep session_id to correlate with your own audit records; you will not need it again to interact with the widget.
Your frontend renders an iframe pointed at the session URL with the launch_token attached. The token in the URL is the only input this flow requires from your frontend. URL-encode the value, since it may contain characters that need percent-encoding.
<iframe
src="https://production.deep-talk.ai/chat?launch_token=lt_7Kx0mQe2vB%2E…"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="clipboard-write"
referrerpolicy="no-referrer"
></iframe>
| Sandbox value | Grants |
|---|---|
allow-scripts |
JavaScript execution inside the iframe. Required: the widget does not work without it. |
allow-same-origin |
Treats the iframe as its real origin rather than an isolated null origin. Required for it to use its own storage and for origin checks to work. |
allow-forms |
Form submission. Required for composing messages. |
Do not grant extra permissions
Leave out allow-popups, allow-top-navigation, allow-modals and allow-downloads unless a specific feature requires them. allow-top-navigation in particular would let the iframe redirect your whole page.
When the iframe loads, Marco validates the token before showing any content.
launch_token against the created session.session_id, ready state and display configuration, derived from the metadata already consumed on the server.READY to the container.Metadata stays on the server
metadata is written once, server to server, and consumed there to configure the session. If your integration needs the frontend to display that context alongside the widget, render it from your own backend.
Note also that READY only means the widget is interactive: the session's metadata is processed only when the user takes an explicit action to begin.
Two independent lifetimes apply. The launch_token lasts five minutes and is single-use: it exists solely to move the iframe from "holds a reference" to "running session". The session runs for a longer, per-integration lifetime, extends with activity, and ends through an explicit close, inactivity, or expiry.
Sessions cannot be resumed
There is no mechanism to resume a session once its launch_token has been consumed or has expired. This is intentional: single use is what makes the token replay-resistant. The only option after a session ends, for any reason, is to create a new one with a new launch_token.
Closing the tab, reloading the page or unmounting the iframe on navigation all produce the same result: the iframe is discarded and the session ends with it. The token was already consumed the moment the previous load succeeded, so it cannot be reused. Your frontend must request a new session from your backend, which issues a new token with the updated context. If instead your container keeps the iframe mounted across navigation, the session stays active until its inactivity timeout or an explicit close.
metadata is fixed when the session is created and cannot be updated on a running session. To point the widget at a different patient or record, request a new session with the new context.
The widget publishes state directly to the containing window with postMessage. Your page listens with a standard listener. Always check event.origin before reading event.data, never compare it against '*', and never trust a message that does not include source: 'marco-widget'.
const MARCO_ORIGIN = 'https://production.deep-talk.ai';
window.addEventListener('message', (event) => {
if (event.origin !== MARCO_ORIGIN) return;
const msg = event.data;
if (msg?.source !== 'marco-widget') return;
switch (msg.state) {
case 'READY': /* the widget is interactive */ break;
case 'STARTED': /* the user sent a query */ break;
case 'COMPLETED': /* session finished */ break;
case 'CANCELLED': /* cancelled by user or app */ break;
case 'ERROR': /* see the errors section */ break;
}
});
| State | Published when |
|---|---|
READY |
The widget validated the token, started the session and is interactive. |
STARTED |
The user sent a query and the interaction is under way. |
COMPLETED |
Ended normally. Includes created_at, finished_at and, optionally, metadata. |
CANCELLED |
Ended early, through user or application action. |
ERROR |
The session ended because of a failure. |
Your container can send commands the same way, targeting the iframe's contentWindow. Always pass the exact target origin as the second argument, never '*', so the browser refuses to deliver the message if the iframe has navigated somewhere unexpected.
const iframeWindow = document.querySelector('iframe').contentWindow;
iframeWindow.postMessage({
source: 'marco-host',
version: 1,
type: 'COMMAND',
action: 'CLOSE', // the only action currently supported
session_id: 'sess_9f2a1c'
}, MARCO_ORIGIN);
On receipt, the widget checks the origin against those authorised for your account before executing. A command from an unregistered origin is discarded silently: the widget neither acts nor emits ERROR, since an unrecognised origin may not be a legitimate sender. A valid CLOSE ends the session immediately and the widget emits CANCELLED back, so your container gets a clean, correlatable confirmation.
{
"source": "marco-widget",
"version": 1,
"type": "STATE_CHANGE",
"state": "COMPLETED",
"session_id": "sess_9f2a1c",
"created_at": "2026-08-20T14:32:00Z",
"finished_at": "2026-08-20T14:36:12Z",
"metadata": {
"query_summary": "Asked about second-line options for HER2-positive…",
"final_synthesis": "Reviewed second-line options for HER2-positive…"
}
}
The metadata object is integration-specific
COMPLETED may include an optional metadata object on top of the base shape. Structured-output needs vary by client, so its shape is not fixed in this guide and must be agreed with your Marco contact before you implement. The example above illustrates the pattern, not a schema. CANCELLED and ERROR always use the base shape.
Errors happen at two moments and surface differently in each. At session creation they are standard HTTP responses to your server-to-server call.
| Code | HTTP | Usual cause |
|---|---|---|
invalid_client_secret |
401 | The credential in the Authorization header is missing or wrong. |
unauthorized_origin |
403 | The resolved account configuration does not allow this environment. |
invalid_request |
400 | A required field was missing or malformed. |
rate_limited |
429 | Too many session-creation calls in a short interval. |
From iframe validation onwards, errors arrive as an ERROR state event with code and message fields.
| Code | Usual cause |
|---|---|
token_expired |
The iframe loaded after the launch_token TTL had expired. |
token_already_used |
The launch_token had already been consumed. |
invalid_origin |
The iframe loaded from a domain outside your allowlist. |
session_not_found |
No session matches the token, or the session was already closed. |
Both session creation and iframe embedding happen over HTTPS only. Mixed content is rejected by the browser before Marco is involved. Every token validation matches the requesting origin against the domains authorised for your account, and rejects with unauthorized_origin even when the token is valid.
A valid launch_token only confirms that your backend's client_secret was presented correctly. It does not guarantee that the given user_id should have access to the record described in metadata: that check is solely your application's responsibility, at the moment your backend calls the endpoint. Once the token is created, the access decision has already been made; the token only carries it.
In this flow your page never calls Marco directly: only your backend and the iframe itself do. So your container page's CSP only needs to allow Marco's origin in frame-src.
Content-Security-Policy:
frame-src https://production.deep-talk.ai;
The iframe holds its session credential in memory after start-up and attaches it explicitly to every subsequent call, avoiding third-party cookie restrictions.
Common to all three flows
Marco does not use conversation content or session metadata to train, fine-tune or improve any model.
Session content and audit records are retained for different periods, because they serve different purposes.
| Data | Purpose | Retention |
|---|---|---|
| Session content | metadata, transcript and summary. Serves the consultation itself. |
Deleted within 48 hours of the session reaching a terminal state. |
| Audit records | Security monitoring and compliance evidence. session_id, external_id where it applies, timestamps and final state, with no clinical content. |
6 years from creation, in line with the industry standard for healthcare access records. |
session_id is the correlation identifier across all three flows, and links a session's events, its audit entries and the COMPLETED payload. In the private-page flow, external_id accompanies it where used. There is no separate correlation ID.
Stability of error codes
Across all three flows, code is a stable identifier and safe to use in business logic; new codes may be added over time, so always include a default case. message is for logs and debugging: its exact wording can change and should not be pattern-matched.
Common to all three flows
Marco offers two environments, with the same paths on both hosts.
| Environment | Host |
|---|---|
| Staging | staging.deep-talk.ai |
| Production | production.deep-talk.ai |
// Public page and private page
Script: https://<host>/widget/widget.js
API base URL: https://<host>/widget
// Server to server
Session creation: POST https://<host>/launch_token
Start chat: GET https://<host>/chat?launch_token=...
Each environment has its own credentials and its own list of authorised origins, and there are no cross-environment routes for any of them.
| Flow | Per-environment credential |
|---|---|
| Public page | Its own appId. A staging appId is not valid against the production host. |
| Private page | Its own shared secret. A signature computed with the staging secret is rejected in production. |
| Server to server | Its own client_secret. A launch_token is only valid in the environment that issued it. |
Store and rotate each environment's credentials separately.
Performance metrics and fixed thresholds, including time to first token and any other agreed latency guarantees, are defined in the SLA, a document separate from this guide.
Common to all three flows
Before enabling any of the three flows, share the following with your Marco contact.
| What we need | What for | Applies to |
|---|---|---|
| Origin(s) where the widget is embedded | Origin verification. This is the list every load or call is matched against. | All three flows |
| Primary and secondary brand colours | Theming the interface to match your site. | All three flows |
| Session duration | Inactivity timeout and maximum lifetime, if the defaults do not suit you. | All three flows |
Shape of the metadata object on COMPLETED |
Agreeing the structured output your integration needs to receive when the session closes. | Server to server |
In return, Marco gives you each environment's credentials: the appId for the public-page flow, the shared secret for the private page, and the client_secret together with the app_id for server to server.
Not sure which flow is yours?
Write to sales@marco.care describing where your application lives and what context data you need to pass. We will confirm the flow and give you staging credentials so you can test before committing.
