Embedding the chat
For the developer at a company that puts its product’s chat inside its own app with Zest. The Embed page (Live › Embed) has your exact snippet, the embed secret and a live preview.
The chat is the same one people get in ChatGPT and Claude: the same intents for their role, the same views, read-back before every change, undo. Every call shows on Activity. People see your product’s name, logo and colors, never Zest.
Install
One line, before </body>, on any page:
<script src="https://zestmcp.app/embed/v1.js" data-zest="<product>" data-key="<public key>" async></script>
data-zest is the product’s short name and data-key its public key, both on the Embed page. The public key is safe in a page: it says which chat to load, nothing more. That line draws a round button in the corner; the chat opens in a panel above it. Everything else (launcher, position, greeting, the suggested intents) comes from the Embed page until you set it in code.
data-site overrides where the chat loads from; it defaults to the address the script came from.
Identity
Your app already knows who is signed in. It tells Zest with an embed token: a short-lived JWT your backend mints and the page hands over. Never put the embed secret, an API key or a password in the page.
| Claim | Required | What it is |
|---|---|---|
sub | yes | The person’s email. Zest finds them by it, or adds them to the company the first time. |
name | no | How they are named in Activity. |
role | no | The role’s name as it is in Zest, like Front desk. Decides which intents they get. |
exp | yes | Seconds since 1970, at most one hour ahead. |
Sign it with HS256 and the embed secret from the Embed page (Identity › Rotate). The secret is shown once; keep it on your server, as ZEST_EMBED_SECRET. Rotating it makes every token signed with the old one stop working.
Then load the script without async and pass the token:
<script src="https://zestmcp.app/embed/v1.js" data-zest="<product>" data-key="<public key>"></script>
<script>
Zest.init({
identity: '<embed token>', // minted by your backend, see below
onLink: (url) => router.push(url), // open links inside your app
});
</script>When the token is about to expire, or someone else signs in, mint a new one and call Zest.setIdentity(token). Each person keeps their own conversation.
Minting the token
import jwt from 'jsonwebtoken';
const token = jwt.sign(
{ sub: user.email, name: user.name, role: 'Front desk' },
process.env.ZEST_EMBED_SECRET, { algorithm: 'HS256', expiresIn: '1h' },
);import jwt, os, time
token = jwt.encode(
{"sub": user.email, "name": user.name, "role": "Front desk", "exp": int(time.time()) + 3600},
os.environ["ZEST_EMBED_SECRET"], algorithm="HS256",
)use Firebase\JWT\JWT;
$token = JWT::encode(
['sub' => $user->email, 'name' => $user->name, 'role' => 'Front desk', 'exp' => time() + 3600],
getenv('ZEST_EMBED_SECRET'), 'HS256'
);require 'jwt'
token = JWT.encode(
{ sub: user.email, name: user.name, role: 'Front desk', exp: Time.now.to_i + 3600 },
ENV['ZEST_EMBED_SECRET'], 'HS256'
)token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": user.Email, "name": user.Name, "role": "Front desk",
"exp": time.Now().Add(time.Hour).Unix(),
}).SignedString([]byte(os.Getenv("ZEST_EMBED_SECRET")))
if err != nil { return err }Config
Every key of Zest.init is optional. What you leave out comes from the script tag, then from the Embed page.
| Key | Type | Default | What it does |
|---|---|---|---|
product | string | data-zest | The product’s short name. |
key | string | data-key | The public key. |
site | string | the script’s address | Where the chat loads from. |
identity | string | none | The embed token for the person signed in. |
email, name | string | none | For a demo page with no backend, while the product is on sample data. Not proof of who someone is: once calls go to the product, only identity counts. |
launcher | 'bubble' 'inline' 'none' | Embed page | Bubble: a round button in the corner. Inline: inside container. None: nothing shows until you call Zest.open(). |
container | CSS selector or element | none | Where the inline chat goes. Full width, and the height it needs up to 640px. |
position | 'right' 'left' | Embed page | The corner for the bubble and its panel. |
greeting | string | Embed page | The first line in the chat. |
suggestions | string[] | Embed page | Up to four sentences, shown as pills before the first message. A tap says it. |
theme | 'product' 'paper' 'dark' { accent, background, text } | Views | The colors around the chat. |
accent | '#rrggbb' | the theme’s | The bubble and the chat’s accent. |
onLink | (url) => void | a new tab | Called when a person opens a link from the chat, so your router can take it. |
On a phone the panel fills the screen and closes with its ✕.
Methods and events
| Method | What it does |
|---|---|
Zest.init(config) | Starts the chat, or starts it again with a new config. |
Zest.open() | Opens the panel. Inline: scrolls the chat into view. |
Zest.close() | Closes the panel. Escape and the panel’s ✕ do the same. |
Zest.say(text) | Opens the chat and says the text as the person, like a pill. |
Zest.setIdentity(token) | Hands over a new embed token. null signs the person out of the chat. |
Zest.on(event, fn) | Calls fn on an event. Returns a function that stops it. |
| Event | fn gets | When |
|---|---|---|
ready | nothing | The chat has loaded. |
open | nothing | The panel opened. |
close | nothing | The panel closed. |
link | the URL | A person opened a link. onLink (or a new tab) has already taken it. |
Zest.on('link', (url) => analytics.track('chat_link', { url }));
document.querySelector('#help').addEventListener('click', () => Zest.say('Who is on my schedule today?'));Theming
The chat takes your product’s name, logo and three colors from Branding, and the look of the views inside it (Paper, Product or Dark, plus your adjustments) from Views. Change them there and every chat, embedded or in an AI app, follows. theme and accent in init change the chat around the views for one page, for instance a dark panel on a dark app.
Allowed origins
On the Embed page, list the sites that may show the chat, one per line, like https://app.yourproduct.com. A chat on any other site answers that it is not allowed there. Empty allows any site: fine for a demo, set it before going live.
If your pages send a Content Security Policy, allow the Zest site in script-src (the script) and frame-src (the chat).
When the person is not signed in
- No token, sample data. On a demo, email and name are enough: the first time, the person is added to the company in the first role.
- No token, real data. With an anonymous role on the Embed page, the person uses the chat as that role, reads only. Without one, the chat answers “Sign in to {product} to use this here.” Mint a token and call Zest.setIdentity.
- A bad or expired token. The chat says what is wrong under the input, and nothing is sent. Mint a new token and call Zest.setIdentity.
- Offline, or Zest cannot be reached. The chat says it did not go through and keeps what they typed.
Versioning
v1 is stable: what this page describes keeps working. Breaking changes go to v2 at a new address, never in place. New config keys and events may be added to v1; unknown ones are ignored.