Zest by Limeade Labs

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:

HTML
<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.

ClaimRequiredWhat it is
subyesThe person’s email. Zest finds them by it, or adds them to the company the first time.
namenoHow they are named in Activity.
rolenoThe role’s name as it is in Zest, like Front desk. Decides which intents they get.
expyesSeconds 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:

HTML
<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

Node · jsonwebtoken
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' },
);
Python · PyJWT
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",
)
PHP · firebase/php-jwt
use Firebase\JWT\JWT;
$token = JWT::encode(
    ['sub' => $user->email, 'name' => $user->name, 'role' => 'Front desk', 'exp' => time() + 3600],
    getenv('ZEST_EMBED_SECRET'), 'HS256'
);
Ruby · jwt
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'
)
Go · github.com/golang-jwt/jwt/v5
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.

KeyTypeDefaultWhat it does
productstringdata-zestThe product’s short name.
keystringdata-keyThe public key.
sitestringthe script’s addressWhere the chat loads from.
identitystringnoneThe embed token for the person signed in.
email, namestringnoneFor 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 pageBubble: a round button in the corner. Inline: inside container. None: nothing shows until you call Zest.open().
containerCSS selector or elementnoneWhere the inline chat goes. Full width, and the height it needs up to 640px.
position'right' 'left'Embed pageThe corner for the bubble and its panel.
greetingstringEmbed pageThe first line in the chat.
suggestionsstring[]Embed pageUp to four sentences, shown as pills before the first message. A tap says it.
theme'product' 'paper' 'dark' { accent, background, text }ViewsThe colors around the chat.
accent'#rrggbb'the theme’sThe bubble and the chat’s accent.
onLink(url) => voida new tabCalled 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

MethodWhat 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.
Eventfn getsWhen
readynothingThe chat has loaded.
opennothingThe panel opened.
closenothingThe panel closed.
linkthe URLA person opened a link. onLink (or a new tab) has already taken it.
JavaScript
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

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.