Skip to main content

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.

ModeWhat the visitor sees
inlineThe application where the element is. If it has a page, the page is drawn beside the conversation (AppRenderer). This is the default.
bubbleA round button in the corner, showing the application's face, that opens the application in a popup: the copilot pattern.
panelThe application at the right edge of the page, at full height.
assistantThe 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:

  1. An Appspec written inside the element, as YAML or JSON:

    <datalayer-app server="http://localhost:8765">
    <script type="application/yaml">
    schema: loop.app/v1
    id: support-desk
    name: Support Desk
    kind: chat
    agent: support-agent
    ui:
    - plugin: chat
    interface:
    accent: sky
    avatar: wizard
    deployment:
    embedded:
    mode: assistant
    </script>
    </datalayer-app>
  2. An Appspec at an address: spec="https://example.com/support-desk.yaml".

  3. 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 raises decision and token-expired events on the element. A chat, widget or worker read with its token is drawn on the page.

  4. A public application, without a token (D-07): app names 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 named at-<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​

AttributeWhat it says
appThe application's id on Datalayer; without a token, a public example's id or an application's address.
originWhere Datalayer is. By default, the origin the script was loaded from.
tokenAn embed token for an application that is not public, issued to your server for each visit.
specThe address of an Appspec (YAML or JSON) to run, instead of app.
modeinline, bubble, panel or assistant. By default, the Appspec's, then inline.
accentgreen, rose, sky, lime, sun or violet. By default, the Appspec's interface.accent; when neither names one, the theme's own colours.
themelight, dark or auto (the visitor's system setting). auto by default.
fontA CSS font family for the application's text. By default, Inter, then the system's sans-serif.
serverAn agent-runtimes server that runs the application's agent. By default, a Datalayer runtime.
apiDatalayer'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.
heightThe height of an inline application in pixels. 640 by default; never less than 240.
resumetrue or false: pick the visit's conversation up again after a reload. true by default.
user-tokenA 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.
languageThe 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 loop theme 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's theme attribute or CSS variable, else interface.theme.mode, else the visitor's system;
  • the same accent and face laid over the conversation's own theme (embedThemeOverrides, the chat's themeOverrides): the chat sets the loop theme 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 (StyleSheetManager targets 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 AppRenderer launches 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 app with 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. AppEmbed takes it as visitors ({ url, app, inferenceUrl }, the renderer's datalayerVisitors).
  • 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 a server, 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_context returns 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_ticket calls functions.open_ticket with 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, and action ({ function, arguments, result | error }) for each function of the page it called, beside decision and token-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.ask in React, else the browser's confirm — 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. context and functions may 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
  1. 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.
  2. 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, and exp at most an hour away.
  3. The page hands it to the element — user-token="…", or element.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_context answers user with it — { sub, name, signed: true } — whatever the page says;
  • an application written in Python reads its sub as session.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 as window-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 as window-message events. It goes to the session the conversation started, on the application's own server (server, with the visit's token when 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 same post, and null when 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:

WhenWhatFiles
With the pageThe loaderdatalayer-app.js
Before the element drawsReact, Primer, the element, AppRenderer, the conversation and its prompt, the A2UI catalogue, the themedatalayer-app-main.js, datalayer-app.css
When it is shownThe companion notebook (JupyterLab's notebook), the document (Lexical), a deck, the agent's details and their chartschunks/
When a message needs itA diagram (Mermaid and its layouts), a highlighted language (one chunk each), a chart (ECharts), a form (rjsf and Ajv), maths fontschunks/, assets/
When it runsWorkers and WebAssembly (Loro); Pyodide, for a browser sandbox, from its CDNassets/

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 draws15.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 shownnothing: all of it was in the script11.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;

  • AppFloating as 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's context and message;

  • 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 says user: signed; the token verified (HS256 only, sub, name, exp at 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's user the 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 as window-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's TestClient). 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 mode winning, 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 public app without 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 as at:<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 of host_<name> dispatched as the chat dispatches a frontend tool, refused, asked or done under each rule, the result sent back;

  • the floating modes: AppRenderer in 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.