Graine AI

Embed an agent on your website

Add a Graine voice or chat agent to your own site with one script tag, in the design you choose in the dashboard, restricted to your domains and handed context from the page.

Embedding puts an agent on your own website. A visitor talks to it there, in the page, without a phone call and without leaving to a hosted link.

Putting an agent inside a mobile app instead? Use in-app agents. The script tag below cannot run there, and an in-app agent can do more: it sees the screen the customer is on and can act on it.

Conversations open on the agent's web-call variant, never on the agent that takes phone calls. The two share a prompt but are separate agents, so changing the embed cannot affect your outbound calling.

Install

Open your agent in the dashboard, go to Embed & Widgets → Install, and copy the snippet. It looks like this:

Paste on every page the agent belongs on
<script async src="https://www.graine.ai/embed/v1.js"
  data-agent-id="your-agent-id"
  data-publishable-key="your-publishable-key"></script>

Paste it into any page you want the agent on — in the <head>, before </body> or through a tag manager. It is async, so it never holds your page up, and the tag itself says which agent to load.

What it costs your page. Until a visitor shows interest — a pointer over the launcher, the keyboard on it, a tap — the page loads the small loader and asks Graine one question (is this key valid here). The chat itself loads the moment interest starts, so it is ready by the time the panel opens. If the agent is the point of the page, add data-preload to load it straight away.

The two-tag form still works and is the one to use when your page passes values from its own script — the signed-in visitor's plan, say:

<script src="https://www.graine.ai/embed/v1.js"></script>
<script>
  GraineAgent.load({
    agentId: "your-agent-id",
    publishableKey: "your-publishable-key",
    variables: { plan_name: "Enterprise" },
  });
</script>

How it looks

Nothing about the look is in the snippet. It is chosen in the dashboard under Embed & Widgets → Appearance, and saving it restyles every page running the agent from its next load — no redeploy, no new snippet.

The design. Appearance opens on the Garage: a showroom of design philosophies, from Graine Classic to Swiss Grid and Neo-Brutalist, each in light and dark, tuned for colour, corners, typeface, launcher, icons, motion and chat bubbles. The widget draws it everywhere it appears:

  • the launcher — its shape (circle, squircle, rounded square, square or a pill with a label), the glyph or your logo, and its shadow or hard offset;
  • the panel — its corners, depth and glass;
  • the chat and the call inside it — the header in the heading font, bubbles in the chosen style, the composer, and every component the agent shows.

The typeface is loaded from Google Fonts inside the widget's own frame, so your site's fonts and styles are untouched and cannot restyle the widget. Light, dark or auto: auto follows each visitor's device and repaints the moment they switch.

The setup sits under the design, saved on its own:

SettingWhat it does
What visitors getA call and a chat, a call only, or a chat only
Opens onWhich of the two opens first, when both are offered
CornerBottom right or bottom left (websites only)
HeadingThe title over the conversation
Launcher tooltipThe hint beside the launcher (websites only), and the label on a pill launcher
Call audioHigh quality (WebRTC), falling back to the standard path if it cannot connect
LogoYour logo — also offered as the launcher glyph
Powered by Graine AIA small line under the widget; off on plans that allow white-labelling
LoadingThe loader and its words while the agent connects, thinks or loads a component

The same design reaches the agent in your mobile app — see Your design.

The publishable key is not a secret

publishableKey ships in your page source, where anyone can read it. That is expected and safe, because the key alone does nothing — it only works from a domain you have allowlisted.

This is why the domain allowlist is the actual security control, not the key. An empty allowlist means the embed will not load anywhere.

Rotating the key immediately breaks every page still serving the old snippet, with no grace period. Update your site first, then rotate — not the other way round.

Restrict it to your domains

In Embed & Widgets → Install, add every hostname the widget may load from:

example.com
www.example.com
app.example.com

Add each host explicitly. example.com does not cover www.example.com — they are different origins to a browser, and the allowlist is matched the way the browser sees it, not the way people write it.

Include your staging hostname too, or the widget will silently fail to load there while working in production.

When a visitor moves to another page

A visitor in the middle of a chat who follows a link to another page of your site finds the conversation where they left it: the same messages on screen, and the agent carries on from them, without greeting them again. On a computer the panel is open as they left it; on a phone, where it would cover the page they asked for, it stays closed and the launcher shows a dot until they open it.

This holds in the same browser tab, for 30 minutes after the last message. A new tab, a new visit, New conversation, or the agent ending the chat starts a fresh one. Nothing is opened until the visitor writes again, so moving between pages costs nothing.

While a voice conversation is going, leaving the page would end it, so the browser asks the visitor to confirm first.

Give the agent page context

If your agent's prompt uses variables, pass them at load time. The visitor never sees them; the agent receives them as fields rather than having to ask.

<script>
  GraineAgent.load({
    agentId: "your-agent-id",
    publishableKey: "your-publishable-key",
    variables: {
      plan_name: "Enterprise",
      account_owner: "Priya",
    },
  });
</script>

They reach the agent however the widget connects — chat, voice or WebRTC — up to 12 keys and 1 KB in all. A key the prompt never names substitutes nowhere; a typo fails quietly rather than injecting text.

What the page learns after load() — the signed-in visitor's first name for {name_user}, the plan they just moved to — goes in GraineAgent.setVariables(): merged into what is set, null under a name removes it, and setVariables(null) clears them all. The values are masked in the browser and reach the next conversation. Who the visitor is goes in GraineAgent.identify(); every trait but the identifier reaches the agent as a variable too.

GraineAgent.setVariables({ name_user: user.firstName, plan: user.plan });
GraineAgent.setVariables({ plan: null });   // removes one

How to map your fields to the prompt's names, on the web and in apps: Send your user's details to the agent.

Different agents on different pages

A single-page app can put a different agent on each route — a sales agent on pricing, a support agent everywhere else. Call load() again with the other agent:

// When the route changes
const agent = path.startsWith("/pricing")
  ? { agentId: "sales-agent-id", publishableKey: "sales-publishable-key" }
  : { agentId: "support-agent-id", publishableKey: "support-publishable-key" };
 
if (GraineAgent.switchesAgents) {
  const onPage = await GraineAgent.load(agent);   // true once the agent is on the page
} else {
  window.location.reload();                       // an older loader: reload to change agent
}
  • The agent on the page comes off first — its panel, its launcher and any conversation in it — and the new one goes on. Only one agent is on the page at a time.
  • The visitor stays signed in: what you passed to identify() reaches the new agent, matched by that agent's own identity setting. Your actions (defineAction) and the page context you described carry over too.
  • load() with the agent already on the page does nothing, so it is safe in a route effect that runs on every render. It resolves true once the agent is on the page, and false when it could not start (the console says why) or a later load() replaced it.
  • identify(null) signs the visitor out of every agent they used on the page.
  • GraineAgent.destroy() takes the agent off without putting another on.

GraineAgent.switchesAgents is true on a loader that switches agents like this (version 1.2.0 and later, GraineAgent.version). An older loader has no flag: reload the page to change agent there.

Let the agent act on the page

The agent can scroll, open, highlight, fill and click on the visitor's behalf. Same contract as the mobile SDK: declare on the agent, implement on the page.

GraineAgent.defineAction("scroll_to_section", ({ section }) => {
  const el = document.getElementById(section);
  if (!el) return { status: "refused", message: "That section is not on this page." };
  el.scrollIntoView({ behavior: "smooth", block: "start" });
  return { status: "ok", message: `Scrolled to ${section}.` };
});

Declare it in the dashboard under Agent → Embed & Widgets → "What this agent may do in your app", with the same name. A handler with no declaration is never offered to the model; a declaration with no handler is refused at call time.

defineAction is safe to call before load() — handlers are held until the widget mounts — and removeAction(name) withdraws one when a section unmounts.

Clicking something the visitor cannot find

The useful pattern is matching on the visitor's own words, not on your markup:

GraineAgent.defineAction("open_faq", ({ question }) => {
  const buttons = Array.from(document.querySelectorAll("#faq .qa button"));
  if (!buttons.length) return { status: "refused", message: "The FAQ is not on this page." };
 
  const wanted = String(question || "").toLowerCase();
  // Word overlap, not exact match: the agent phrases the question the way the
  // visitor asked it, never the way you wrote it.
  const best = buttons
    .map((b) => ({ b, hits: wanted.split(/\W+/)
        .filter((w) => w.length > 3 && (b.textContent || "").toLowerCase().includes(w)).length }))
    .sort((x, y) => y.hits - x.hits)[0];
 
  if (!best || best.hits === 0)
    return { status: "refused", message: "No FAQ matches that." };
 
  best.b.click();
  best.b.scrollIntoView({ behavior: "smooth", block: "center" });
  return { status: "ok", message: "Opened that answer on the page." };
});

Pointing at something

GraineAgent.defineAction("highlight", ({ section }) => {
  const el = document.getElementById(section);
  if (!el) return { status: "refused", message: "Nothing to highlight there." };
  el.scrollIntoView({ behavior: "smooth", block: "start" });
  const previous = el.style.outline;
  el.style.outline = "3px solid #3FC98C";
  setTimeout(() => { el.style.outline = previous; }, 2400);
  return { status: "ok", message: "Highlighted it." };
});

Navigate with window.location, not window.open. The handler runs from a callback inside the widget's frame, and a popup opened from there is exactly what popup blockers exist to stop. The visitor sees nothing happen and the agent has already said it opened.

What to return

ReturnThe agent says
{ status: "ok", message }Repeats your message as a completed action
{ status: "refused", message }Explains why, using your reason
{ status: "error", message }Apologises and offers an alternative

A refusal with a real reason beats a false success. "No such section. Available: top, pricing, faq" gives the agent something useful to say next; ok on a scroll that did not happen tells the visitor to look at something they cannot see.

Answer within about five seconds. The agent waits for your handler. For anything slower, return immediately and report the outcome through setScreen when it finishes.

Interactive components

A plain web chat can only send text, which makes the visitor do the typing — read three plans and type which one, spell out an email, type a date. Every one of those is somewhere people give up.

Components replace typing with a tap, and hand your agent a structured answer instead of a sentence it has to interpret:

ComponentThe visitorYour agent receives
Lead formFills name / phone / email, taps Submitfull_name, phone, email as fields
Plan comparisonSwipes cards, taps oneThe exact plan id and price
Info cardReads, optionally taps a call-to-actionWhich card, which action
Calendar slotsPicks a day, taps a timeThe exact day and slot
Quick actionsTaps one of a row of repliesWhich reply

A component is a tool your agent calls, not a special prompt syntax. You enable it on the agent; the model decides when to show it.

Your own button and status

The loader draws a launcher in the design you chose. To put the call behind your own button instead — the engine hidden, your page drawing every state — see Your own UI → Website. WebRTC works either way: the loader vouches for your site with a ticket from your own domain, and a page of your own answers the engine's graine:need-ticket the same way. Never add Graine's own domain to Allowed domains.

Troubleshooting

What you seeWhyFix
Nothing rendersThe page's origin is not allowlistedAdd the exact hostname, including www. if that is what visitors use
Worked, then stoppedThe key was rotatedCopy the new snippet from Embed & Widgets → Install
Loads on production, not stagingStaging host missingAdd the staging hostname
Agent ignores your variablesThe variable is not declared on the agentAdd it to the agent's prompt variables first
The old look after saving a designThe page loaded the widget before you savedReload the page — the design is read when the widget loads
A chat-only agent shows a call, or the other way roundThe page asked for something the agent does not offerNothing to fix: What visitors get in the dashboard decides. A snippet can narrow it, never widen it, and the browser console says when it was asked to
The chat says "This chat isn't available right now"The agent is switched off or was deleted, or your account is out of credits. Visitors are never told whichSwitch the agent on, or add credits. An open chat tries again every minute and comes back on its own; nobody needs to reload

Next steps