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 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:
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:
| Setting | What it does |
|---|---|
| What visitors get | A call and a chat, a call only, or a chat only |
| Opens on | Which of the two opens first, when both are offered |
| Corner | Bottom right or bottom left (websites only) |
| Heading | The title over the conversation |
| Launcher tooltip | The hint beside the launcher (websites only), and the label on a pill launcher |
| Call audio | High quality (WebRTC), falling back to the standard path if it cannot connect |
| Logo | Your logo — also offered as the launcher glyph |
| Powered by Graine AI | A small line under the widget; off on plans that allow white-labelling |
| Loading | The 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:
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.
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.
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:
- 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 resolvestrueonce the agent is on the page, andfalsewhen it could not start (the console says why) or a laterload()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.
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:
Pointing at something
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
| Return | The 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:
| Component | The visitor | Your agent receives |
|---|---|---|
| Lead form | Fills name / phone / email, taps Submit | full_name, phone, email as fields |
| Plan comparison | Swipes cards, taps one | The exact plan id and price |
| Info card | Reads, optionally taps a call-to-action | Which card, which action |
| Calendar slots | Picks a day, taps a time | The exact day and slot |
| Quick actions | Taps one of a row of replies | Which 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 see | Why | Fix |
|---|---|---|
| Nothing renders | The page's origin is not allowlisted | Add the exact hostname, including www. if that is what visitors use |
| Worked, then stopped | The key was rotated | Copy the new snippet from Embed & Widgets → Install |
| Loads on production, not staging | Staging host missing | Add the staging hostname |
| Agent ignores your variables | The variable is not declared on the agent | Add it to the agent's prompt variables first |
| The old look after saving a design | The page loaded the widget before you saved | Reload the page — the design is read when the widget loads |
| A chat-only agent shows a call, or the other way round | The page asked for something the agent does not offer | Nothing 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 which | Switch the agent on, or add credits. An open chat tries again every minute and comes back on its own; nobody needs to reload |

