For Developers → Embed
One script tag,
a chat widget.
Paste one <script>block into your dashboard and your visitors get a guuey agent in a launcher bubble — chat, generative UI, no build step. It runs inside its own iframe on guuey's origin, so your page's CSS, JS, and CSP never touch it. This page is the whole contract: the snippet, anonymous vs. identified mode, and the rule that decides who's allowed to embed it. Prefer to build your own chat surface instead? The raw /agent/invoke contract is the appendix at the bottom. Everything here is what private-beta builders run today; the public beta opens in August 2026 — join the waitlist to get an app id of your own.
The snippet
Paste it, cross-origin by design.
Find your app id with guuey apps list (guuey apps create prints one for a new app), then paste:
<!-- guuey widget. Add this site to the app's allowed domains or the embed is refused. -->
<script>
window.guuey = window.guuey || function(){(guuey.q=guuey.q||[]).push(arguments)};
guuey("init", {
app: "app_abc123",
});
</script>
<script src="https://widget.guuey.com/v1.js" async></script>The first <script>block is a small queue shim, not decoration — it has to load before the second, async tag arrives, and it's what makes the load order stop mattering: any guuey("init", …) call made before v1.js finishes downloading queues up and replays automatically once it does. Paste the whole block verbatim, in this order.
Identity
Anonymous by default. Identified when you configure it.
Omit identity, as in the snippet above, and every visitor chats anonymously — no signup, no config, and it works the moment your app is deployed.
<!-- guuey widget. Add this site to the app's allowed domains or the embed is refused. -->
<script>
window.guuey = window.guuey || function(){(guuey.q=guuey.q||[]).push(arguments)};
guuey("init", {
app: "app_abc123",
identity: {
getToken: (reason) =>
fetch("/api/guuey-token" + "?reason=" + reason).then((r) => {
if (!r.ok) throw new Error("token endpoint failed: " + r.status);
return r.text();
}),
},
});
</script>
<script src="https://widget.guuey.com/v1.js" async></script>getToken is how your backend hands the widget a token for the visitor already signed into your app. The widget asks for one before its first message and re-asks with reason: "expired" after a 401 — a host that caches its token must re-mint on that signal, or every turn after the first expiry fails for that visitor.
Which mode an embed runs in is decided by the app, not by the snippet. An app configured for end-user identity (auth mode byo, below) runs every embed identified; any other app runs anonymous, because a token minted for it could not be verified. The two have to match: configure the app but leave identityout of the snippet and the widget says it can't verify the session rather than quietly answering as a guest — deliberately, since a silent fallback would file that visitor's conversation somewhere they can never reach it.
Two ways to configure it. Mode A — bring your own OIDC issuer (the same Bring Your Own Auth path the raw API supports; see Identity in the appendix below): guuey apps update --auth-mode byo --issuer-url … --audience …. Your JWKS must be served from <issuer>/.well-known/jwks.json — guuey builds that URL directly and never reads openid-configuration, which is the most common mode-A integration failure. Mode B — a guuey-issued keypair for teams with no IdP of their own: guuey widget keys create <appId> --audience … prints an app secret once, and your backend signs each visitor's token with it using @guuey/widget-auth. The private half is KMS-sealed and never leaves the platform.
Anonymous limits
What an anonymous visitor gets.
- Chat and generative UI — identical to an identified visitor.
- Threads that survive a page reload, in that browser.
- Not durable memory, a cross-app profile, or a durable home directory — those need a real, durable identity (see Identity above).
- Continuity is best-effort. The widget keeps its guest identity in the iframe's own storage — a cookie can't survive a third-party frame at all — so a browser that partitions or clears that storage (Safari today, and increasingly others) starts every page load as a new visitor. The chat still works; it just stops remembering the last one.
Framing
Configure Allowed Domains, or it refuses to load.
An app with no allowed origins configured is not embeddable: the widget page answers every request with Content-Security-Policy: frame-ancestors 'none', and every browser refuses to render it in your page — fail closed, not a soft warning. Add your site under Settings → Tools → Allowed Domains in the console, or from the CLI: guuey apps update --domains example.com (comma- separate more than one).
One trap worth knowing: a bare domain (example.com) covers every subdomain for the raw API's CORS check below, but the framing check has no such wildcard — list the exact origin you embed on (https://app.example.com), or that page still gets refused even with the apex configured.
Allowed Domains is public.It's served on the agent's unauthenticated card and disclosed by the CSP header itself — anyone who loads your widget can read the same list. Don't park an internal or unreleased hostname in it.
Advanced
Bring your own UI instead.
Every guuey-hosted agent also answers a public endpoint over Server-Sent Events, independent of the widget above. Call it with fetch or curl, or drop in the @guuey/agent-client React hook for a working chat surface in about fifteen lines. Skip this section if the script tag above is enough.
The endpoint
One POST, one stream.
guuey deployprints your agent's invoke URL — the app's id as a subdomain of the agents domain. POST a single user turn as input; there's no messagesarray to manage and no client-side conversation state to serialize. It's a Bedrock-style one-turn-in, SSE-stream-out contract.
POST https://<appId>.<agents-domain>/agent/invoke
Content-Type: application/json
{
"input": "What's the weather in Tokyo?",
"threadId": "optional — omit on the first turn, replay it on the next",
"clientMessageId": "optional — idempotency key for this turn"
}threadId comes back on the session event below — save it and send it on the next turn to continue the same conversation. clientMessageId lets you safely retry a dropped request without duplicating the user's message.
The response
Four event types, one stream.
event: session
data: {"sessionId":"...","userId":"...","authMode":"anonymous","threadId":"..."}
event: message
data: {"type":"text.delta","delta":"Tok"}
data: {"type":"text.delta","delta":"yo is 22°C and clear."}
… one or more per turn — plus turn/message/tool lifecycle events
event: done
data: {"stopReason":"end_turn","threadId":"...","userSeq":1,"agentSeq":2}
event: error
data: {"code":"...","message":"..."} // only on failuressession opens the stream and carries your resolved identity plus the threadId to persist. message fires one or more times per turn, normalized to AgJSON — text streams as text.delta. done closes the turn with a stopReason (end_turn / max_turns / error / aborted). error only appears on failure — a clean turn never sends one. (The message payloads above are trimmed for illustration — real events carry additional fields like seq, id, and turnId.) Parsing this by hand works, but the SDK below already reduces the stream into plain text for you.
Try it
curl, no SDK required.
curl -N -X POST "https://<appId>.<agents-domain>/agent/invoke" \
-H "Content-Type: application/json" \
-d '{"input": "one user turn as a plain string"}'-Ndisables curl's output buffering, so you see the stream as it arrives instead of all at once at the end.
Identity
Three ways to be someone.
- Guest cookie (browsers, automatic) — the first call mints an HttpOnly
guuey_guestcookie; every browser request after that replays it, no code required. x-guuey-guestheader (native clients, and the widget above) — apps without a cookie jar generate and persist their own 64-character hex secret and send it on every request instead.Authorization: Bearer(signed-in users) — Guuey sign-in access tokens are always accepted; if the app's Users → Authentication mode is set to Bring Your Own Auth, tokens from your own OIDC provider are verified against its published keys too. An invalid Bearer never falls back to a guest — it's a 401.
In the public beta, Bring Your Own Auth end users get durable per-user agent memory too — "my agent remembers me" won't be just for Guuey sign-in. One caveat to plan for now: a user's identity is derived from your issuer URL and their subject claim together, so changing your app's identity provider resets your users' agent memory — every existing user gets a new derived identity on the new issuer, with no stored memory attached to it. Plan issuer changes accordingly.
CORS
Allow your origin.
This is the raw API's gate, not the widget's. The widget's iframe is served from widget.guuey.com and calls the pod from that same origin, so cross-origin CORS never applies to it — what gates the widget is the frame-ancestorsrule above. Allowed Domains configures both, though: it's what the raw API checks on every browser call below, and it's the exact list the widget's framing header is built from — one setting, two independent enforcement points.
By default your agent answers browser calls from guuey's own surfaces (any https://*.guuey.com origin) plus localhost/127.0.0.1on any port — enough to test from your app's Playground tab in the Console or any local dev server, not enough to call it directly from your own domain. Add your site under Settings → Tools → Allowed Domains in the console: a bare domain (example.com) covers https on the apex and every subdomain; include a scheme for an exact origin instead (https://checkout.example.com, or http://192.168.1.20:5173 for a LAN IP dev server). Console access comes with your beta invite.
The SDK
@guuey/agent-client
npm install @guuey/agent-client"use client";
import { useMemo } from "react";
import { createWebAdapters } from "@guuey/agent-client";
import { useAgentInvoke } from "@guuey/agent-client/react";
export function AgentChat({ appId }: { appId: string }) {
const adapters = useMemo(() => createWebAdapters(), []);
const { messages, send, isStreaming, error } = useAgentInvoke({
endpointUrl: `https://${appId}.<agents-domain>`,
appId,
adapters,
});
return (
<div>
{messages.map((m, i) => (
<p key={i}><strong>{m.role}:</strong> {m.text}</p>
))}
{error && <p>{error}</p>}
<button disabled={isStreaming} onClick={() => void send("Hello!")}>
Send
</button>
</div>
);
}createWebAdapters() with no arguments talks as an anonymous guest — the same guest-cookie identity as a plain fetch call, no sign-in needed. useAgentInvoke owns the SSE parsing, the threadId round-trip, and message state, so the snippet above is a complete (if minimal) chat surface.
createWebAdapters also takes apiBaseUrl(your app's public API base, ending in /v1), getAccessToken (an async function returning the caller's signed-in bearer token), and getGuestSecret(a sync function returning an anonymous guest secret your own storage manages — the widget's own anonymous mode is exactly this, keyed per app rather than per cookie). Set apiBaseUrl plus either identity function and the hook also fetches GET {apiBaseUrl}/threads/{threadId}/messages on mount, repainting a caller's transcript before they type anything — so their chat survives a page reload. Skip them and anonymous guest-cookie chats still work identically on the pod (the persisted threadIdcarries the conversation forward); only the browser's visible history needs a reload to forget, since the read plane can't identify a cookie-only guest.