Graine AI

Embed an agent on your website

Add a Graine voice or chat agent to your own site with two script tags, restrict it to your domains, and hand it 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 runtime agents, so changing the embed cannot affect your outbound calling.

Install

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

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

Paste it into any page you want the agent on, just before </body>.

Two script tags rather than one: the first loads the loader, the second configures it. Keeping them separate means the loader is cached across your whole site while the configuration stays per-page — so you can pass different context on a pricing page than on a support page.

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 Install & domains, 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.

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>

Only variables your agent already declares are accepted; unknown keys are ignored rather than passed through, so a typo fails quietly instead of injecting unexpected text into the prompt.

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 runtime is blocked on 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.

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 Install & domains
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

Next steps

On this page