Embed SelfDecode's AI health chatbot in your own app, scoped to one of your end-customer
SelfDecode's AI chatbot can be embedded directly in your web application as an <iframe>. The chatbot answers health questions using the genetic reports, lab results, and lifestyle data already generated for your profiles through the SelfDecode API.
The embedded chatbot is built on top of sub-users: each chat session is pinned to one sub-user and can only see that sub-user's linked profiles and conversations. Your servers never expose a SelfDecode credential to the browser — your backend mints a short-lived, single-use handoff token per session, and the embedded page exchanges it for a secure, browser-managed session cookie.
Prerequisites
Your contract must include the
sub_usersandchatbotfeatures, and you need a sub-user created for the end-customer you're embedding for. Chatbot usage is billed monthly as a line item on your invoice.
What we host
Everything browser-side is served by us under https://{env}selfdecode.com/chatbot-iframe/. You host none of it:
| URL | What it is |
|---|---|
/chatbot-iframe/decody-embed.js | The embed shim — the only script you include. It creates the iframe, relays the OTT over postMessage, renews the session, and forwards branding. |
/chatbot-iframe/ | The chatbot page that runs inside the iframe. It exchanges the OTT for the HttpOnly session cookie and runs the chat. |
/chatbot-iframe/demo.html | A demo harness for trying the embed without integrating — see below. |
Try it before you build
Mint an OTT with issue-iframe-handoff (see step 2 below), open https://selfdecode.com/chatbot-iframe/demo.html, paste it into the OTT field, and hit Load / reload iframe — you get a live chatbot session with no integration work at all.
The demo also exposes every branding field as a live control, so you can dial in brand_color, fonts and the rest against a real session and copy the resulting config straight into your own page. It calls updateConfig on each change, which is the same path your app would use for something like a dark-mode toggle. There is a dark-theme preset and a reset button to start from.
Because the OTT is single-use with a 60-second life, you need a fresh one each time you load the iframe.
Personalizing the chatbot
Two sub-user fields shape the conversation:
name— the chatbot addresses the user by this name.context— initial context only (max 2000 characters), injected into the chatbot's instructions at the start of every session (e.g. "Preparing for a marathon; primary goal is sleep quality"). Use it to seed the conversation with what you already know. You don't need to maintain it over time: the chatbot has its own persistent memory and remembers preferences and facts the user shares across conversations.
Embedding flow
Three steps, the first of which runs once:
- Create the sub-user (once per end-customer) and link their profiles — see sub-users.
- Expose a handoff endpoint on your backend that mints a handoff token with
issue-iframe-handoff. - Drop the embed shim on your page and point it at your handoff endpoint. It renders the chatbot and keeps the session alive.
Step 2 — Your handoff endpoint
The embed uses the handoff endpoint only — never a sub-user bearer token (issue-token), which is for server-to-server calls and must stay on your servers.
Add an endpoint to your backend (for your own logged-in users) that calls issue-iframe-handoff and returns the ott. The token is valid for 60 seconds and can be used exactly once, so mint it on demand — not ahead of time. The shim calls your endpoint whenever it needs one: on load, and again to renew the session when it expires — so keep it fast and idempotent. If the user has logged out of your app, respond with an error instead; the chat session will not be renewed.
import os
import requests
env_domain = os.getenv("SD_ENV") # "qa-b2b." for testing, "" for prod
base_url = f"https://{env_domain}selfdecode.com/service/b2b-integrations"
sub_user_id = "7c8a1c0e-4b7e-4e2d-b7de-91a3f2b6c554"
response = requests.post(
f"{base_url}/sub-user/{sub_user_id}/issue-iframe-handoff/",
headers={"Authorization": f"Bearer {os.getenv('ACCESS_TOKEN')}"},
json={
"session_ttl": 3600,
"mcp_config": {
"functions": ["exploration", "memory"],
"features": ["reports", "labs"],
},
},
)
ott = response.json()["ott"]
Response:
{
"ott": "1oPq0v6bcallLPT3blCWuKZOSXjfE2rG_g0DPBEnl4M",
"expires_at": "2026-07-06T09:01:00Z"
}
Request body:
| Field | Type | Default | Description |
|---|---|---|---|
session_ttl | integer (60–86400) | 3600 | How long the chat session stays valid, in seconds. After it expires the user needs a fresh handoff. |
mcp_config | object, optional | — | Restricts what the embedded chatbot can do. Omit for the full capability set of your contract. Unknown keys and unknown values are rejected with a 400. See below. |
allowed_origins | string[], optional | — | Origins where you will embed the iframe, e.g. ["https://app.yourdomain.com"]. When set, the session can only be established on those pages — a handoff token used anywhere else is rejected and voided. Matching is exact on the full origin (scheme + host + port). Omit the field to disable pinning — an empty list is rejected (400). Strongly recommended in production. |
Step 3 — Embed the chatbot
Include the shim and mount the chatbot with a getOtt function that calls the endpoint from step 2. The shim loads the chatbot page from its own location, so the environment follows the script tag — there is no URL to configure:
<div id="decody" style="height: 700px"></div>
<script src="https://{env}selfdecode.com/chatbot-iframe/decody-embed.js"></script>
<script>
const handle = Decody.mount('#decody', {
getOtt: async () => {
const res = await fetch('/your-backend/decody-handoff', { method: 'POST' });
if (!res.ok) throw new Error('handoff failed');
return (await res.json()).ott;
},
config: { feature_name: 'Ask HealthBot', brand_color: '#0055ff' }, // optional branding
onCost: ({ sub_user_id, external_id, cost }) => {}, // optional usage callback
onError: (reason) => console.warn('Decody:', reason), // optional
});
</script>
That's the complete browser-side integration. The shim creates the iframe, performs the initial handoff, and automatically calls your getOtt again to renew the session when it is about to expire (session_ttl controls how often). The OTT travels only via postMessage, never in a URL, so it can't leak through referrers, history, or logs. onError fires for unrecoverable states — 'handoff-failed' (your endpoint errored) and 'cookies-blocked' (the user's browser blocks third-party cookies) — plus the non-fatal 'invalid-config' (a branding field was invalid; the chatbot keeps running on defaults). handle.destroy() unmounts the embed.
If you put the iframe in a sandbox, add allow-popups and allow-popups-to-escape-sandbox alongside allow-scripts and allow-same-origin. File downloads open a presigned URL in a new tab, and a sandbox without those tokens makes them fail silently. An unsandboxed iframe needs nothing.
Branding the embed
The optional config restyles the chatbot to match your product. All fields are optional; anything invalid or omitted falls back to the default (reported via onError('invalid-config'), never fatal). The demo page lets you try every one of these live before writing any code.
| Field | Values | Default |
|---|---|---|
feature_name | Chat header title (display-capped 50 chars) | AI |
icon | atom, bot, message, sparkle, sparkles | sparkle |
text_font | proxima-nova, inter, manrope, work-sans, ibm-plex-sans, system | proxima-nova |
display_font | proxima-nova, poppins, space-grotesk, sora, fraunces, dm-serif-display | inherits text_font |
background_color | Any CSS color | #ffffff |
brand_color | Any CSS color | #4f46e5 |
font_color | Any CSS color — main body text | #101828 |
title_font_color | Any CSS color — the header title and brand icon, so you can tint just the lockup | inherits font_color |
sub_font_color | Any CSS color — secondary text | derived: a blend of background_color toward font_color |
Every named font falls back to Proxima Nova and then a generic family, so text stays legible whatever loads. Note that sub_font_color has no fixed default — it is mixed from your background and text colours, so a dark background_color gets a readable secondary tone without you setting anything.
To change branding on a live embed (e.g. a dark-mode toggle), call handle.updateConfig(cfg) — a full replace that applies immediately and persists across session renewals.
Usage reporting
If you want live usage attribution in your own app, pass onCost: it fires after every chatbot turn that incurred cost (including cancelled turns, which are charged for partial work) with { sub_user_id, external_id, cost }. There is no cumulative total — sum client-side if you need one; your monthly invoice is the source of truth.
The handoff token is a single-use lookup key with a 60-second life, not a credential. The actual session credential only ever exists inside an HttpOnly cookie the browser manages.
White-labelling
Embedded sessions are white-labelled by default — there is nothing to configure. Inside an iframe session the chatbot never names SelfDecode or the platform behind it, even if asked directly, and refers to whoever the user reached it through as "your provider". Anything locked, unavailable, or requiring a purchase is presented as something their provider handles: the chatbot never points the user at an outside site, dashboard, or store. If the underlying account lapses, the user sees "This assistant is currently unavailable. Please contact your provider." rather than a SelfDecode reactivation prompt.
Restricting capabilities with mcp_config
mcp_configmcp_config narrows what the embedded chatbot may do for this session. Restrictions are combined with your contract — the chatbot can never do more than your contract allows, only less.
functions — what kinds of operations are enabled
functions — what kinds of operations are enabled| Value | Enables |
|---|---|
exploration | Reading and searching health data (reports, labs, genes, topics) |
mutation | Writing data: logging lab results, updating the supplement regimen, answering questionnaires |
pdf_generation | Generating downloadable PDF reports (requires exploration) |
code_sandbox | Running code in an isolated sandbox to work over the user's own data — analysis, charts, tables, and custom-built documents |
memory | Remembering user preferences and facts across conversations |
feedback | Letting the user submit feedback about answers |
Omitting functions enables all of them. An empty list disables all optional functions (the chatbot can still converse, but not act on data).
A few user-visible capabilities are composed from these values rather than mapping one-to-one:
- Standard report PDFs —
pdf_generation. Lab-result PDFs additionally require thelabsfeature. - Custom documents —
code_sandbox. The chatbot writes and runs code to build a document to the user's request (e.g. a formatted summary of their own results), then publishes it as a download. Withoutcode_sandboxit can still deliver standard PDFs ifpdf_generationis granted, but cannot compose bespoke ones. - Logging labs from a photo or document —
mutation. - Re-opening the user's own uploads — always available; no function class required.
features — which data areas are available
features — which data areas are availableAny subset of your contracted features. Omitting features grants everything your contract includes.
Listing a feature your contract does not include is rejected with a 400 — the handoff fails rather than quietly issuing a narrower session. Validate your mcp_config against your contract, not against this list: the table below covers the features with chatbot behaviour, and your contract may grant others that are accepted here without changing the embed.
Features narrow what the chatbot can look up, not just what it can generate. Where a feature is missing, the matching search/explore scope is not merely empty — it is not offered to the model at all, so the chatbot will not promise the user something it cannot reach.
| Feature | What it covers in the chatbot |
|---|---|
reports | The core health-topic library — the profile-wide overview ("Health Launchpad"), diet and fitness reports, and standard topic reports, along with each report's own recommendations and report PDFs. This is the backbone: without it the chatbot can converse but has no personalized topic data to draw on. |
action_plan | The user's regimen — supplements, habits and diet: reading it, and updating it (with mutation) — plus the general recommendation library the regimen is built from. Recommendations that belong to a specific report come with reports instead. |
labs | Blood and urine markers: searching and exploring markers, reading the user's results, logging new results (with mutation), and lab-result PDFs. |
genes | Gene and SNP lookups — the user's variants and genotypes, by gene symbol or rsid. |
pgx | Pharmacogenomics from array/SNP-chip data: the PGx overview and per-drug interactions. |
wgs_pgx | The whole-genome PGx report (also admits per-drug lookups). |
wgs_rare_variants | Whole-genome rare pathogenic variant screening. |
ancestry | Ancestry composition and mitochondrial DNA / maternal lineage. |
carrier_status | Carrier status for inherited conditions. |
acmg | ACMG-guided predisposition screening (whole genome only). |
bio_chemistry | Biochemical pathway analysis. |
longevity | Longevity Screener analysis. |
lifestyle_assessment | Lifestyle assessments: searching and exploring them, the guided questionnaire flow in chat, and editing answers (with mutation). |
health_overview, custom_snp | The corresponding report topics, where the profile has them. |
clinical_overview | REST API surface only — no chatbot behaviour hangs off this today. Include or omit it without affecting the embed. |
Health topics in general and symptom / health-goal discovery are always available, as is guided profile onboarding — no feature gates them.
auto_generate — when the chatbot may generate reports
auto_generate — when the chatbot may generate reportsA boolean, defaulting to true when omitted, controlling whether this session may create billable jobs on demand (report generation on missing, opt-in-gated topics, PDF generation). When false, the session is read-only over pre-generated, pre-opted-in data.
Security model
The embedded session inherits every guarantee of sub-user scoping — automatic profile filtering, fail-closed endpoint access, no privilege inheritance, isolation between sub-users. The iframe handoff adds:
- No credential in the browser. Your API token stays on your servers. The browser only ever sees a 60-second single-use handoff token and an
HttpOnlysession cookie it cannot read. - Single-use handoff. A replayed handoff token is rejected. If two exchanges race, exactly one wins.
- Embed-origin pinning. With
allowed_originsset, a handoff token only works on your pages — the chatbot page reports the browser-verified embedding origin to the exchange, so even a stolen token is useless anywhere else. - Private conversations. Chat threads are partitioned per sub-user — neither other sub-users nor your parent account sessions can see them.
- Sandbox isolation. With
code_sandboxgranted, code runs in an isolated per-session sandbox over that sub-user's data only — it is not a path to the wider API or to another sub-user's profiles. - No commerce. Embedded sessions cannot make purchases and are never shown upsell prompts.
Billing
Each chatbot reply has a cost based on model usage. Costs accrue per message and are summed into a chatbot line item on your monthly invoice — you are billed for actual usage, with no prepaid credit management required.
Errors
| Status | Cause |
|---|---|
| 400 | Validation error — e.g. session_ttl out of range, unknown mcp_config key or value, a features entry your contract does not grant, pdf_generation without exploration, or an empty allowed_origins |
| 401 | Missing or expired bearer token |
| 403 | Contract does not include the required feature |
| 404 | Unknown sub-user id, or (on the exchange) an expired/replayed handoff token |
