Embedding an application
An application can sit in another product's page. A host adds one script tag and one element:
<script src="https://datalayer.app/embed/datalayer-app.js" async></script>
<datalayer-app app="01M…" origin="https://datalayer.app"></datalayer-app>
The element draws the application in the mode its Appspec gives, in the loop
theme with the application's accent. The host page's styles do not reach the
application, and the application's styles do not reach the page. A host that
uses React mounts the same renderer as a component, AppEmbed.
The four modes
The Appspec gives the mode in deployment.embedded.mode. The host can give a
different one with the mode attribute.
| Mode | What the visitor sees |
|---|---|
inline | The application where the element is. If it has a page, the page is drawn beside the conversation (AppRenderer). This is the default. |
bubble | A round button in the corner, showing the application's face, that opens the application in a popup: the copilot pattern. |
panel | The application at the right edge of the page, at full height. |
assistant | The application's character on the page, speaking in a balloon (the floating assistant). The character is the avatar the Appspec names in interface.avatar, the paper clip when it names none, looked up among the avatars contributed (or a 3D model, {kind: model3d, url}, drawn as it says): Datalayer's four, and in React whatever the host's plugins contribute. An id nothing contributes is said in place of the application. |
Every mode draws the application with the same renderer, AppRenderer: its
kind's preset, its layout, its page, its starters and its agent. The three
floating modes put it in ChatFloating's window (floating-small, panel,
assistant), which keeps its chrome around the workspace:
- the window's header: the application's face, its name and Close;
- the bubble's button, which blinks and rings while the application has said something not yet read, and shows those words in a balloon when hovered;
- the panel at the right edge;
- the assistant's character, which acts what the application is doing (thinking, working, waiting for you), says its newest words in its balloon while the window is closed, and is dragged about and sent away (for the page, the session or for good).
The workspace tells the window what the application is doing and what it last
said (AppRenderer's onPresence and onSaying). The conversation is mounted
once, when the element is drawn, and kept while the window is closed. A
floating mode takes no room in the page, because everything it draws is fixed
to the viewport.
Where the application comes from
The element looks in three places and uses the first one it finds:
-
An Appspec written inside the element, as YAML or JSON:
<datalayer-app server="http://localhost:8765"><script type="application/yaml">schema: loop.app/v1id: support-deskname: Support Deskkind: chatagent: support-agentui:- plugin: chatinterface:accent: skyavatar: wizarddeployment:embedded:mode: assistant</script></datalayer-app> -
An Appspec at an address:
spec="https://example.com/support-desk.yaml". -
An application on Datalayer:
app="<id>", read with its embed token (token). A decision application is framed as before: Datalayer's run page in an iframe that sizes itself to the page and raisesdecisionandtoken-expiredevents on the element. A chat, widget or worker read with its token is drawn on the page. -
A public application, without a token (D-07):
appnames an example Datalayer keeps warm for visitors (web-research,customer-interview) or an application at its address (app="demo-accounting") that its owner lets anybody open. The element reads it the way Datalayer's own pages read it for somebody signed out — the example from the catalogue the bundle carries, the address from ai-agents (GET …/apps/deployments/at/<slug>, no credentials) — then draws it in its mode and runs it on the visitors' runtime: the conversation mints a visitor's token from ai-inference for that one application (POST …/anonymous/token {app}), minted again before it runs out, and talks to the runtime with it. An address's agent is made there as the element opens, before its conversation is ready, and is namedat-<slug>. Nothing is launched and nothing is kept; the visitor's limits are the runtime's (ten turns a day, fifteen minutes a session). An application a visitor may not talk to — a private address, one that reaches its owner's connections or documents, an example the runtime does not keep — says so in one sentence where the element is, in ai-agents' words, and names the embed token as what an embed of it takes. Nothing is drawn, asked or launched for it.
Attributes
| Attribute | What it says |
|---|---|
app | The application's id on Datalayer; without a token, a public example's id or an application's address. |
origin | Where Datalayer is. By default, the origin the script was loaded from. |
token | An embed token for an application that is not public, issued to your server for each visit. |
spec | The address of an Appspec (YAML or JSON) to run, instead of app. |
mode | inline, bubble, panel or assistant. By default, the Appspec's, then inline. |
accent | green, rose, sky, lime, sun or violet. By default, the Appspec's interface.accent; when neither names one, the theme's own colours. |
theme | light, dark or auto (the visitor's system setting). auto by default. |
font | A CSS font family for the application's text. By default, Inter, then the system's sans-serif. |
server | An agent-runtimes server that runs the application's agent. By default, a Datalayer runtime. |
api | Datalayer's services, where an application is read with its token (Spacer) and, without one, read and run for a visitor (ai-agents, ai-inference, the visitors' runtime beside them). By default, the platform's: for a visitor, Datalayer's AI plane (https://r1.datalayer.run), where ai-agents, ai-inference and the visitors' runtime answer, read from the plane table (DEFAULT_PLANE_URLS in @datalayer/core), never a literal. ai-inference is resolved on its own: api, else the platform's aiInferenceUrl, else the plane's. api overrides both. |
height | The height of an inline application in pixels. 640 by default; never less than 240. |
resume | true or false: pick the visit's conversation up again after a reload. true by default. |
user-token | A token your server signed with the deployment's secret, naming your user: what an application that says user: signed opens its session with. See Who your user is. |
language | The language the visitor reads the application in, as BCP 47 tags it (fr, pt-BR): its translation into it (interface.translations) and the chat's own words, in English, French, Spanish, German, Italian or Portuguese. By default, the visitor's browser's. A value that is no tag is refused in place. |
The element reads its attributes again when they change. A new mode,
accent, theme, font, height, resume or language redraws the application. A new token
is handed to a framed run page, which keeps what the person has entered.
Setting the element's token property sets the attribute.
The element refuses a value that is not valid and shows a one-sentence
message in its place. For example, mode="popup" shows "popup" is not a
mode; it is inline, bubble, panel or assistant. A font is accepted only as a
font-family is written: names, quotes, commas and spaces. Nothing that
could close the declaration or load a file is accepted.
The theme, and what a host overrides
The theme travels inside the element. The application is drawn in the element's shadow root, which contains:
- the bundle's stylesheet (
datalayer-app.css, next to the script); - the
looptheme as CSS custom properties — or the theme the application names in its Appspec (interface.theme.variant:datalayer,spatial,lovely,matrix,earth,sand,ivory,sun,loop,vscode) — set on the theme's own element inside the root, with the accent, when one is named, applied in light and dark over whichever theme it is (embedThemeStyles). The colour mode is the host'sthemeattribute or CSS variable, elseinterface.theme.mode, else the visitor's system; - the same accent and face laid over the conversation's own theme
(
embedThemeOverrides, the chat'sthemeOverrides): the chat sets thelooptheme again inside it, and without them its bubbles and its button would wear the theme's default mint and face whatever the embed set around it. Inline, floating and as the assistant alike; - the theme's own stylesheet, with its controls drawn as pills, scoped to that
element. A stylesheet in the host's
<head>cannot reach into a shadow tree, so the element writes this one into the root itself; - the styled-components rules (
StyleSheetManagertargets the root) and Primer's overlays (menus and dialogs are drawn in a portal root inside it).
all: initial on the element resets everything the host page would otherwise
pass down, such as a font, a colour, a line height or a text alignment.
Inside, the theme sets every custom property it uses again, so a host
variable with the same name never reaches the application. The theme never
writes to the host's <body>.
A host can change three things: the accent, the face (the typeface) and the mode (light or dark). Each can be set with an attribute or with a CSS variable from the host's own stylesheet. An attribute wins over a variable:
datalayer-app {
--datalayer-app-accent: violet;
--datalayer-app-font: 'Source Sans 3', sans-serif;
--datalayer-app-theme: dark;
}
Nothing else needs to be overridden for the application to look at home. A
face given through font replaces the theme's face everywhere the theme uses
it: the body, the font stacks and every font shorthand. The host page loads
the face itself, because a @font-face rule inside a shadow root does not
apply.
Where the agent runs
- On a Datalayer runtime (the default for an Appspec written in the
element or at an address). It is launched the way
AppRendererlaunches it: a runtime is allocated, and the application's agent is created there with the application's spec in its payload, so the runtime enforces the application's rules on every tool call. It needs a visitor Datalayer knows: the launch uses the signed-in user's credentials. A floating mode says so to a visitor who is not signed in, and launches nothing. - On the visitors' runtime (a public
appwith no token, D-07, R-30): nothing is launched; the runtime kept warm beside ai-agents answers a visitor's token for the application, in every mode.AppEmbedtakes it asvisitors({ url, app, inferenceUrl }, the renderer'sdatalayerVisitors). - On the host's own agent-runtimes server (
server="…"). The agent is created there with the application's spec unless it already exists, as the workspace's Local target does.
In every mode the conversation talks to the application's agent through its
session API, over AG-UI at
<runtime>/api/v1/apps/agents/<id>/ag-ui/: each thread is a session.
Private applications
A private application is embedded with an embed token scoped to that one application and the deployment the visit runs. The host's server is issued one for each visit, with the owner's API key, which stays on the server:
curl -X POST "https://prod1.datalayer.run/api/spacer/v1/apps/<id>/embed-token" \
-H "Authorization: Bearer $DATALAYER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ttlSeconds": 3600, "visit": "<your visit id>", "deployment": "<its embedded deployment uid>"}'
The deployment is the token's alone: the element has no attribute for it,
and a token that names none is refused in a sentence before anything is
drawn (the embed token names no deployment…). Its spoken answers, when its
Appspec gives it a voice, are said for that deployment and counted against
its spend limit. The element reads the application with the token and raises
token-expired when the token lapses. The host then sets a new one.
What is not ready yet:
- The app embed token is issued by Spacer on prod1 (naming its deployment since 15 October 2026).
- The token reads the application, asks a decision's typed questions, and
runs a session of its deployment (R-20): on the host's own
agent-runtimes server (
server), the element hands the visit's token to the conversation, and the server lets it into a live embedded deployment of that application only — as its principal, each visit apart, only reading (see An embed's visitor). It never launches a Datalayer runtime: a runtime is launched with a person's credentials, and one kept by each deployment waits (R-33). Without aserver, a private chat, widget or worker runs embedded only for a visitor signed in to Datalayer; for any other visitor, the element says why it cannot run, and launches nothing. A public one runs for any visitor on the visitors' runtime (D-07, above). - Allowed origins per deployment (
deployment.embedded.origins) are not yet enforced on the server (D-12).
After a reload
On your own agent-runtimes server (server) with an embed token, a
visitor's conversation survives a page reload. The element keeps the
session it opened in your page's localStorage, under
datalayer-app:session:<application id>:<visit>: the visit is the one the
token names. It keeps the session's uid only, never the conversation.
After a reload, your server issues the page a token for the same visit
(the visit you pass to POST …/apps/<id>/embed-token). The element asks
the server for that session's conversation
(GET <server>/api/v1/apps/sessions/<uid>/messages, see
the session API), draws
it again and continues it. A new token for the same visit (token-expired,
then a new token) redraws the application on the same session.
A session can be gone or refused. The runtime may have let it go (it is not resumed from its record), it may have restarted, or the token may name another visit. In that case the element forgets the stored uid, starts a new conversation and says so once above it: Your earlier conversation with <name> has ended, so this is a new one.
To keep nothing in the page's storage, write resume="false", or pass
resume={false} to AppEmbed. The session then lasts only as long as the
page: a renewed token keeps it, and a reload starts a new one. Without
storage (blocked, or in a private window), the element behaves the same way.
Nothing is kept for an application on a Datalayer runtime, or without a
token.
The conversation comes back as words: what was said and answered. A page that an answer drew is not drawn again until the next answer.
In React
import { AppEmbed } from '@datalayer/agent-runtimes/lib/apps';
<AppEmbed
app={appspec}
mode="assistant" // the Appspec's by default
accent="sky" // the application's by default
colorMode="auto"
font="Georgia, serif"
serverUrl="http://localhost:8765" // a Datalayer runtime when unset
embedToken={token} // the visit's, on serverUrl
resume={false} // keep nothing in the page's storage (on by default)
language="fr" // the visitor's browser's by default
plugins={[OwlCharacterPlugin]} // characters beside Datalayer's four
/>;
plugins are the host's own plugins for the assistant mode: the characters
they contribute to loop.assistant.character are there beside Datalayer's
four, so an Appspec may name one of them (interface.avatar: owl). The
element takes no plugins, so an embedded application draws one of the four.
AppEmbed is the renderer the element draws. In a React host it uses
Primer's portal root, and the styles go where the host's bundler puts them.
Pass ownPortal only when the component is mounted in a root of its own.
The avatar alone, at the application's address
On Datalayer, an application's own address draws its page with its conversation. When its Appspec says so —
deployment:
hosted:
slug: support-desk
avatar_alone: true # false unless said
— the address shows only its avatar instead, the conversation opening in
its balloon, as the assistant mode does in another product's page: the
avatar the Appspec names (interface.avatar), else the paper clip,
whatever the visitor chose in their own settings. AppFloating is that
floating chrome on its own, for a host that already has its theme and passes
its renderer what else it needs (renderer: the UI plugins its organization
turned off, the visitors' runtime, the agent kept on):
import {
AppFloating,
embedAssistantCharacter,
} from '@datalayer/agent-runtimes/lib/apps';
const chosen = embedAssistantCharacter(appspec);
'problem' in chosen ? (
<p>{chosen.problem}</p>
) : (
<AppFloating
app={appspec}
view="assistant"
character={chosen.character}
colorMode="light"
themeOverrides={{}}
renderer={{ pluginsOff: [] }}
/>
);
The page and the application, talking
An embedded application can read what the page passes it, call functions of
the page, and tell the page what it did (D-10). What it may read and
call is named in its Appspec, under deployment.embedded.host, and each is
a tool its agent calls — host_context for the values, host_<name> for a
function — decided by the rule that names it, as any tool of the
application is.
Underneath, the page is a Reactor plugin of its own (RU-7,
hostPlugin): each function is a command of it, host.<name>
(host.context for the values), and its agent tools bundle (host)
names them for the agent as host_<name> and host_context. The chat turns
the bundle into tools that run the commands — as it does any plugin's — and
hands them to the application's agent wherever it runs; the rule check, the
question to the person and the action event are the command's, so a
command run from anywhere else (a palette, a test) is decided the same way.
deployment:
embedded:
mode: assistant
host:
context: [user, page, plan]
functions:
- name: open_ticket
description: Open a ticket in the shop's helpdesk for this customer
parameters:
type: object
properties: { title: { type: string } }
required: [title]
rules:
- action: Read what the page says
applies_to: host_context
behaviour: do_it
- action: Open a ticket
applies_to: host_open_ticket
behaviour: ask_first
The page gives the values and the functions to the element, and listens to what it says:
<datalayer-app app="01M…" token="…"></datalayer-app>
<script>
const app = document.querySelector('datalayer-app');
app.context = { user: { name: 'Ana', plan: 'pro' }, page: location.pathname, plan: 'pro' };
app.functions = {
open_ticket: async ({ title }) => (await fetch('/tickets', { method: 'POST', body: title })).json(),
};
app.addEventListener('message', event => console.log('It said', event.detail.text));
app.addEventListener('action', event => console.log('It called', event.detail.function, event.detail));
app.addEventListener('token-expired', () => renewToken(app));
</script>
- In.
host_contextreturns the values the Appspec names, as the page passes them at that moment (values), and says the ones it has not passed (unsaid); anything else the page passes is never read. - Offered.
host_open_ticketcallsfunctions.open_ticketwith the arguments the agent gives, and returns what it returns (result) or why not (error): the page offers no such function, or it failed. - Out. The element raises
message({ id, text }) once the application has answered, andaction({ function, arguments, result | error }) for each function of the page it called, besidedecisionandtoken-expired. - Ruled. Each call is decided in the page by the rule that names its
tool, before the page is called: a tool no rule names, or leave it to
me, is refused (
hostRefused), and the checks say it (No rule names “host_refund”…); ask me first and do it if I asked ask the person on the page —host.askin React, else the browser'sconfirm— and a call they do not allow is refused; do it is done. The runtime decides by the same rule what its model is shown: a function no rule names, or leave it to me, is never offered to it, and one the page does not name in the Appspec neither. A call it makes is run by the page, never by the runtime — the runtime hands it back to the page — so the page's decision is the one that stands. - Set any time.
contextandfunctionsmay be set as soon as the element is written, before the script has defined it: what was set is taken over when it is.
In React, AppEmbed takes the same as host:
{ context: () => ({ user, page }), userToken, functions, onEvent, ask, onWindowPort }.
Who your user is, when it matters
What the page passes as user is a claim: anybody's page can say anything,
and so can anybody who opens the browser's console on it. An application
that acts in each of your users' names, or shows data that is theirs, takes
only a user your server signed (D-21):
deployment:
embedded:
host:
context: [user]
user: signed # claimed unless said
- The secret. Each embedded deployment has one, made by its owner on
the Ship tab (Embedded, Sign your users): shown once, kept among the
owner's secrets, and made again with Rotate — the old one stops working
at once. Keep it on your server, as
DATALAYER_APP_USER_SECRET_<deployment uid>; it never goes in a page. - The token. Your server signs a short token for the user who is signed
in to your product: HS256 only, with
sub(your own id for the user),name, andexpat most an hour away. - The page hands it to the element —
user-token="…", orelement.userToken = …(in React,host.userToken: () => token) — and it goes with every run of the conversation (forwardedProps.loop.user_token).
Signing it in Python, with PyJWT:
import os, time
import jwt # pip install pyjwt
SECRET = os.environ["DATALAYER_APP_USER_SECRET_01JZ…"]
def user_token(user) -> str:
return jwt.encode(
{"sub": user.id, "name": user.full_name, "exp": int(time.time()) + 15 * 60},
SECRET,
algorithm="HS256",
)
And in Node, with jsonwebtoken:
import jwt from 'jsonwebtoken';
const SECRET = process.env['DATALAYER_APP_USER_SECRET_01JZ…'];
export const userToken = user =>
jwt.sign({ sub: user.id, name: user.fullName }, SECRET, {
algorithm: 'HS256',
expiresIn: '15m',
});
<datalayer-app server="https://shop.example/assistant" token="…" user-token="…"></datalayer-app>
The runtime verifies it where the session opens, with the secret it was
given — your own server keeps it in its environment; a Datalayer runtime of
the owner's is given the owner's secrets as it starts — and opens no session
without it, in a sentence: 401 with no token (… acts in the name of each
of its users: the page hands over a token its server signed naming the user
(user-token), and none was sent.), 403 with one that is not signed with
the deployment's secret, has expired, lives longer than an hour, or says no
sub or name, and 503 on a runtime not given the secret (it names the
variable, never the secret). The user it names is the session's:
host_contextanswersuserwith it —{ sub, name, signed: true }— whatever the page says;- an application written in Python reads its
subassession.user; - a page the application saves says it wrote it for them (I-10): its byline
(Written by Shop help, for Ana Lopez.) and its metadata (
for_user).
It is verified once, as the session opens: a token that expires later does not end the conversation, and a new one goes with the next session.
Window messages
An application written in Python also talks to the page outside the conversation, with window messages (P-25):
- Out. Its code's
await session.send_window_message(data)— anything JSON writes — is raised on the element aswindow-message(event.detail.data), during a turn of the conversation or in answer to the page. The person does not see it. - In.
element.postWindowMessage(data)hands the page's message to its code's@app.window; what that answers comes back aswindow-messageevents. It goes to the session the conversation started, on the application's own server (server, with the visit'stokenwhen there is one): before the conversation has started, or off that server, the promise is refused in a sentence; an application whose code reads none refuses it (… reads no window message: its code has no @app.window.). In React,host.onWindowPort(post)is handed the samepost, andnullwhen it no longer applies.
<datalayer-app server="https://shop.example/assistant" token="…"></datalayer-app>
<script>
const app = document.querySelector('datalayer-app');
app.addEventListener('window-message', event => highlight(event.detail.data));
document.querySelector('#checkout').addEventListener('click', () =>
app.postWindowMessage({ page: '/checkout' }),
);
</script>
Trying it on a host page
examples/embed-host/index.html is a plain page on an origin of its own
that writes the script tag and the element, passes context, offers one
function (open_ticket) and logs every event the element raises. Serve it
beside the bundle and open it:
python3 -m http.server 8788 --directory examples/embed-host
# http://127.0.0.1:8788/?embed=http://localhost:3063/embed/datalayer-app.js&app=web-research&api=https://r1.datalayer.run
Its query names the script, the origin, Datalayer's services, the application and the mode (see its README). The function is asked for only by an application whose Appspec names it.
How a call is decided is tested without a browser
(src/apps/__tests__/host-bridge-runtime.test.ts): a runtime stood in for
asks the page to run host_open_ticket the way a runtime asks the chat to
run a frontend tool; the page finds the tool by its name, runs it and sends
the result back as the tool's result, as ChatBase does; the runtime answers
from it. Under no rule, or leave it to me, the call is refused before the
page is called and the runtime reads the refusal; under ask me first and
do it if I asked the person on the page is asked, and a no refuses it;
under do it the page's function runs and nobody is asked. Whether the
runtime's own enforcement sees a tool the page runs is still not verified
against a runtime (see Not done yet).
One suite, two mounts
The embed and the hosted page pass the same end-to-end suite (D-15):
e2e/mounts.spec.ts, run with npm run test:e2e in the system Chrome, one
page at a time. Its two mounts are the landing's hosted page of an address
(/apps/<slug>) and the element on the host page above, each running the
same cases: the page loads with its conversation ready; a visitor's message
is answered (one model request per mount); a component — a table, asked for
— is drawn in that answer; an address a visitor may not talk to shows
ai-agents' refusal and asks no model. e2e/global-setup.ts probes the
landing (E2E_LANDING_URL), the bundle at its /embed/ or at
E2E_EMBED_URL (the loader, and datalayer-app-main.js answered with
Access-Control-Allow-Origin, which a host page on another origin needs to
import it), the visitors' runtime (E2E_DATALAYER_API),
the address open to visitors (E2E_ADDRESS) and the one that refuses
(E2E_REFUSED_ADDRESS, demo-accounting) before any browser opens; what is
not reachable skips its cases with a sentence. With an address named, both
mounts talk to that one application — the element embeds the address, not an
example — so the same application passes the same cases on both.
Mounted from Python
A Python application serves itself, its page and its session API from a
FastAPI of your own — app.mount(api, path="/assistant") — and the page at
/assistant is the element above, its Appspec written in it and its
server that path. Embed it in your own pages the same way:
<datalayer-app server="https://your.site/assistant"> with the Appspec
written in the element, its origin named in deployment.embedded.origins.
See Mounted into your own FastAPI.
The snippet, from Ship
embedSnippetOf writes the snippet a host pastes. It writes the mode only
when one is given; the Ship tab gives the Appspec's:
import { embedSnippetOf } from '@datalayer/agent-runtimes/lib/apps';
embedSnippetOf({ origin: 'https://datalayer.app', app: '01M…', mode: 'assistant' });
// <script src="https://datalayer.app/embed/datalayer-app.js" async></script>
// <datalayer-app app="01M…" origin="https://datalayer.app" mode="assistant"></datalayer-app>
What loads when
The script a host writes, datalayer-app.js, is a classic script of a few
hundred bytes. It imports the element's module from beside it, and the module
imports, from beside it in turn, only what it draws:
| When | What | Files |
|---|---|---|
| With the page | The loader | datalayer-app.js |
| Before the element draws | React, Primer, the element, AppRenderer, the conversation and its prompt, the A2UI catalogue, the theme | datalayer-app-main.js, datalayer-app.css |
| When it is shown | The companion notebook (JupyterLab's notebook), the document (Lexical), a deck, the agent's details and their charts | chunks/ |
| When a message needs it | A diagram (Mermaid and its layouts), a highlighted language (one chunk each), a chart (ECharts), a form (rjsf and Ajv), maths fonts | chunks/, assets/ |
| When it runs | Workers and WebAssembly (Loro); Pyodide, for a browser sandbox, from its CDN | assets/ |
Every file is found relative to the one that asks for it, so /embed/ can be
served from any Datalayer origin. The module and its chunks are ES modules: a
host on another origin fetches them with CORS, so the origin serving
/embed/ answers Access-Control-Allow-Origin: * (Datalayer's does).
When a file does not come, the element says so in a sentence where it would have drawn: The application could not be loaded: … did not load in place of the element when the module fails, The notebook could not be loaded: …, The chart could not be drawn: … or The form could not be drawn: … in place of a part.
The sizes, gzipped, as CI's build measured them on 2026-10-08 (kB and MB of 1000 bytes):
| Before (1.3.93) | Now | |
|---|---|---|
| Fetched before the element draws | 15.7 MB (one script of 61.3 MB) and 4.2 MB of CSS (8.7 MB, its fonts and images inlined) | 2.36 MB (a module of 8.8 MB) and 197 kB of CSS (1.07 MB) |
| Fetched only when shown | nothing: all of it was in the script | 11.7 MB in 978 chunks, each fetched when what it draws is shown, and the fonts and images as files |
The build fails when what is fetched before the element draws — the module and
the chunks it imports statically — is over 2.5 MB gzipped
(INITIAL_GZIP_BUDGET in vite.embed.config.ts), and prints every file's
size and the initial load by package, with how each is reached. The budget
sits a little above today's size, so a change that adds to the first load is
seen, and moved behind an import(), before it ships. What still weighs in the
initial load is the conversation itself: it is written against JupyterLab's
services and @datalayer/jupyter-react (its kernel indicator, its notebook
store, its theme), streamdown with KaTeX for answers, the browser agent's AI
SDK and the agentspecs catalogue. Taking those out is the way below the budget.
Building the script
npm run build:embed
# dist-embed/datalayer-app.js — the script a host writes: it imports the module
# dist-embed/datalayer-app-main.js — the element's module
# dist-embed/chunks/ — what is fetched when shown
# dist-embed/assets/ — fonts, images, WebAssembly, workers
# dist-embed/datalayer-app.css — the stylesheet the element links into its shadow root
Serve the whole folder from a Datalayer origin at /embed/.
A release builds it: the release workflow runs npm run build:embed, and the npm package @datalayer/agent-runtimes carries dist-embed/ from 1.3.93 on, so the element is versioned with the runtime it talks to. Pull requests build it too, so the sizes are seen before a release. The Datalayer landing copies dist-embed from the package it installs into its /embed/, and refuses a production build whose package has none.
What is tested, and what is not
Tested (vitest, src/apps/__tests__/embed-config.test.ts,
embed-element.test.tsx, embed-session.test.tsx,
src/chat/base/__tests__/chatThread.test.tsx):
-
after a reload: the visit read from the token, the session kept under a namespaced key (in storage, or in memory only with
resume="false"or without storage), asked for again on the host's server with the token, its conversation handed to the chat and drawn, the next message sent on its thread with it, the runtime's agent-wide snapshot never drawn over it; a session that is gone or refused forgotten, a new one started, and said once; a renewed token asking again. The runtime's side (test_app_embed_sessions.py): a visit reads its session's conversation, another visit and a session the runtime let go get a 404; -
what stays out of the first load (
embed-weight.test.ts): the plugin graph not in the preset and the plugins panel naming reactor-graph's plugin without importing it, the agent's details and the companion notebook imported when shown, ECharts, rjsf and Ajv when a chart or a form is drawn. The build measures the load against its budget in CI. Not seen in a browser: the element on a host page, fetching its module and chunks from/embed/; -
the modes and the chat each one floats in;
-
AppFloatingas the avatar alone, on the visitors' runtime too; -
the host's tools (
host-bridge.test.ts): the values named and no others, a function called and told to the page, one no rule names refused before the page is called; the element'scontextandmessage; -
who the user is (
host-bridge.test.ts,embed-element.test.tsx; the runtime's side,test_app_host_user.py): the signed token sent with every run, the newest, only for an application that saysuser: signed; the token verified (HS256 only,sub,name,expat most an hour away, this deployment's secret) as the session opens, over the session API and over AG-UI; refused without it, with a bad one, or without the secret;host_context'suserthe signed one whatever the page said; the byline and metadata of what it saves. Not run against a deployed runtime, nor in a browser; -
window messages (
embed-session.test.tsx,loopWindow.test.ts): a post refused before the conversation has started, then sent to its session on the host's server, what the code answers raised aswindow-message, and what its code says in a turn raised the same way; the runtime's side and the mount (test_app_mount.py, with FastAPI'sTestClient). Not seen in a browser; -
the order of the overrides (attribute, then CSS variable, then Appspec) and the messages for invalid values;
-
the snippet;
-
the theme with the accent and the host's face, the reset and the scoped stylesheet;
-
the element: drawn in its shadow root in the Appspec's mode, the host's
modewinning, the assistant with the Appspec's character (or the paper clip, or one a host plugin contributes, or the sentence saying none does), the accent and the face inside the conversation, a decision framed, a lapsed token reported; a publicappwithout a token run as a visitor on the visitors' runtime in all four modes, and an address a visitor may not talk to said in place (embed-element.test.tsx,visitor-app.test.ts: an example kept warm, one not kept, an address read from ai-agents with no credentials and run asat:<slug>, ai-agents' refusal with the token named, a private address, Datalayer out of reach); -
the host's functions from a runtime's side (
host-bridge-runtime.test.ts): a tool call ofhost_<name>dispatched as the chat dispatches a frontend tool, refused, asked or done under each rule, the result sent back; -
the floating modes:
AppRendererin the window, on Datalayer or on the host's server, told to report what it does and says; the sentence for a visitor who is not signed in, with nothing launched; -
the window around a host's conversation (
chat-floating-conversation.test.tsx): the conversation under the window's header, opened and closed with the conversation kept, the balloon on the bubble while words are unread, and the assistant acting the reported state and saying the reported words.
Not done yet:
- A reload has not been tried in a browser against a running server.
- The element has not been seen in a browser loading its loader, its module
and its chunks from
/embed/on a host page (the bundle is built by the release and by PR CI, under its 2.5 MB gzipped budget). The end-to-end suite (npm run test:e2e) is written for it and for the hosted page, and was not run against either: it needs the landing's dev server serving the rebuilt bundle, and an address open to visitors. - A public application without a token, run as a visitor in the element, has not been seen in a browser either: tested as above, not on a host page.
@scope, which scopes the theme's stylesheet, needs a recent browser. Where it is missing, the controls keep Primer's corners.- Two embeds on one page share Primer's portal root: the second one's overlays win.
- The host's tools have not run against a runtime: whether the runtime's
own enforcement of the rules sees a call of
host_<name>— a tool the page runs — is not verified. If it does, it knows no class for it, and an embed's visitor, whose turns only read, would be refused it there.